Autonomous proportional chemical dosing for swimming pool automation
Part of the APA Devices product family.
Version 3.17.3 · AVR · ESP · STM32 · No required dependencies
Proportional dosing control
- True proportional output — both PWM speed and pulse duration scale continuously with error; never bang-bang on/off
- Closed-loop feedback — 2 sensor readings averaged before each dose, 3 after; verifies the water actually moved toward setpoint
- Adaptive dose correction — first failed dose gets +30 % PWM boost; second gets +50 % PWM and doubled pulse time (capped at 5 min); alarm fires only after three consecutive failures — no human intervention needed between attempts
- Dose delivery health monitoring — EMA-learned baseline continuously tracks each pump's normal chemical delivery;
getDoseEffectiveness()returns 0–100 (last dose as a % of the learned baseline; 100 until baseline established after 3 doses);ALARM_INEFFECTIVEfires automatically when delivery drops belowsetEfficiencyThreshold()(default 20, active out of the box; pass 0 to disable);getLastDoseSensorBefore()/getLastDoseSensorAfter()expose raw before/after sensor averages for display or logging; checkhasDoseHistory()first - Adaptive proportional band — optional self-learning mode: after each feedback cycle the library nudges the effective band up when the sensor overshot, down when it undershot, converging toward the pool's true chemical response; disabled by default, enabled with
enableAdaptivePB(nudgePct)(1–25% per cycle); learned value is EEPROM-persistent per pump - Pool volume scaling — one call (
ApaDose::setPoolVolume(m3)) scales pulse duration, rest period, and feedback timing for pools between 10–90 m³; reference is 20 m³; pools above ~30 m³ require this to converge to setpoint; disabled by default for backward compatibility; survivesfactoryReset() - Dead-band — optional noise filter (
ApaDose::setDeadbandPct(pct), 0–20% of proportional band) suppresses dosing when the error is small; asymmetric entry/exit hysteresis prevents oscillation at the boundary; same % value applies correctly to both pH and ORP; cleared byfactoryReset() - Over-setpoint alarm — if the sensor stays on the wrong side of setpoint for more than 30 minutes,
ALARM_OVER_SETPOINTfires to notify the operator that chemistry has drifted past target without intervention; when a dead-band is configured the alarm mirrors the same band width on the opposite side of the setpoint (with dead-band off, any persistent over-setpoint triggers it); auto-clears the moment the reading returns to the dosing zone — no acknowledge required - pH-first dosing priority (J) —
clPump.setPhPump(&phPump)links the CL pump to the pH pump; CL dosing is automatically suspended when pH exceeds 7.6 (CL_PH_MAX), where chlorine is mostly ineffective; resumes when pH returns below threshold; off by default - Cross-settle coupling (A) —
clPump.setCrossSettleMinutes(n)holds CL dosing for N minutes after each pH dose, preventing the pH/ORP see-saw caused by acid doses temporarily depressing ORP during mixing; off by default; requiressetPhPump()first
Safety
- Full alarm system — wrong direction, ineffective dose, safety band, daily dose limit, sensor fault (
ALARM_SENSOR_FAULT) — all built-in and reported via callback or polling - Filtration interlock — dosing blocked the instant the filter stops; a running dose halts immediately; no chemical ever injected into stagnant water
- External stop — optional callback from any external system (maintenance mode, backwash, cover) blocks all dosing immediately; a mandatory 5-minute settling time applies after the signal clears before dosing resumes
- Chemical tank empty sensor — optional dry-contact callback (
setTankEmptyCallback()) firesALARM_TANK_EMPTYthe instant the tank runs dry; blocks dosing and priming until the tank is refilled and acknowledged; zero SRAM cost if unused - Over-feed alarm (OFA) — protects against over-dosing by tracking how many minutes each pump runs each day; call
setOFALimit(30)once insetup()to set a 30-minute reference for a 20 m³ pool — the library scales the limit automatically for other pool sizes if you calledsetPoolVolume(); at 70 % of the limit a status warning fires (dosing continues); at 90 %ALARM_OFAfires, dosing stops, and the ACK button is required — pressing ACK resets the counter immediately so dosing can resume straight away; the counter also resets automatically at midnight so unattended systems recover on their own without operator attention;getOFAPct()returns today's usage (0–100 %) for a dashboard row; disabled by default - Dynamic OFA (dOFA) — self-learning over-feed protection; always active, zero configuration needed; dOFA learns what a normal dosing day looks like for THIS pool and fires
ALARM_OFAwhen today's proportional run time exceeds 2× the learned baseline (warning status at 1.5×); no limit to guess — the library builds it automatically from real daily usage; both dOFA and fixed OFA coexist independently, whichever fires first controls; baseline is EMA-averaged over the last N days (default 10, adjustable 3–14 viasetDOFAAdaptDays()), persisted to EEPROM at midnight and survives power cycles; the baseline is ready from day 2 onwards — one qualifying day (≥ 5 min proportional run) is sufficient; during warm-up the pool is protected byALARM_INEFFECTIVE,ALARM_WRONG_DIRECTION,ALARM_SAFETY_BAND, the daily dose limit, and optional fixed OFA;isDOFALearning()returnstrueduring warm-up;getDOFAPct()returns today's proportional run as a % of the learned baseline; callresetDOFA()at spring opening to restart learning after a seasonal shutdown; sensor-less pumps are inert (proportional dosing never runs, counter stays 0) - Setpoint range enforcement — pH 6.8 – 7.8 and ORP 400 – 850 mV enforced on every write; out-of-range values rejected before reaching EEPROM
- Inter-pump chemical lockout — 90-second enforced gap after any pump instance doses; prevents incompatible chemicals meeting at the same pipe inlet
- Startup blackout — optional N-minute dosing hold after power-on (
blackoutMinutesparameter inbegin()); gives electrochemical sensors time to stabilize before the first dose decision;isInStartupBlackout()exposes the state for display
Flexibility
- 1 to 4 independent pumps — each instance is a full isolated state machine with its own dosing cycle, feedback loop, alarm state, and EEPROM block
- Sensor-less pump support — pass
nullptras the sensor callback for flocculant or algaecide pumps; filtration interlock, daily limit, and priming all remain active - Solenoid valve support — set
min == maxinsetPumpRange()for time-proportional on/off control; no other code changes needed - Manual dosing —
triggerManualDose()for button or RTC-triggered doses; duration clamped to 5 minutes regardless of what is passed; blocked by most safety guards — exception:ALARM_OFAdoes not block manual doses so the operator can intervene and add chemical without acknowledging the alarm first - Scheduled dosing —
setScheduledDose()arms a recurring dose at a configurable wall-clock time; optional interval (daily/weekly/fortnightly) and sensor threshold to skip when reading is already in range; all safety guards apply; requires RTC - Pipe priming —
triggerPrime()fills dry pipes on installation or after a container swap; bypasses all safety guards so it works even under an active alarm — exceptALARM_TANK_EMPTY(no point running a dry pump); no rest period is imposed after priming — consecutive primes are allowed immediately (useful for long pipe runs requiring multiple passes) - Shock / super-chlorination —
triggerShock()doses chlorine at full power until ORP reaches a target or a time ceiling; automatic early-stop margin, ORP rise check, inter-pump interlock, and post-shock safety band suppression all built-in; hobbyist and pro overloads available - Dosing window — restrict automatic dosing to a configurable daily hour range via
setDosingWindow(); manual doses and priming are unaffected
Monitoring
- Chemical volume tracking —
getDailyVolumeMl()andgetLastDoseVolumeMl()estimate consumption from actual pulse duration and PWM intensity; resets at midnight when an RTC is connected - Tank level estimation — two independent optional features, each usable alone: the software path (
setTankCapacity(litres)) estimates remaining volume from dose pulses — no hardware sensor required;getTankRemainingPct()(0–100 %) andgetTankDaysUntilEmpty()(rolling 7-day estimate in days; 255 = not enough data yet) update after every dose;ALARM_TANK_EMPTYfires when estimated consumption reaches capacity; the hardware path (setTankEmptyCallback()) firesALARM_TANK_EMPTYinstantly from a float switch or dry-contact sensor — see Safety Systems → Chemical tank empty sensor; combined, the hardware sensor is the sole alarm authority and the software estimation provides the percentage display — real-time alert from hardware, visual progress on a dashboard;acknowledgeAlarm()resets the consumed counter; disabled by default; persisted to EEPROM at midnight - Dose counter —
getDailyDoseCount()tracks combined automatic and manual doses per day; resets every 24h — at real midnight with an RTC, every 24h from boot without one - Rest period queries —
getSecondsUntilNextDose()returns seconds remaining in the current rest period (0 when ready to dose);getSecondsSinceLastDose()returns seconds elapsed since the last dose completed (0 if no dose yet this session) — both are useful for dashboards and LCD status rows - System status snapshot —
getSystemStatus(buf, size)fills a caller-supplied buffer with a single-line summary of the current state (active alarms, dosing phase, sensor value, daily dose count); sizeAPA_DOSE_STATUS_BUFFER_SIZE(96) is sufficient for the longest output
Engineering
- EEPROM persistence — setpoint, band, and dosing type survive power loss; magic-number and checksum validation on every boot with automatic fallback to safe defaults
- RTC scheduling — optional: daily counter reset at midnight, dosing window by hour; library works fully without an RTC
- Non-blocking — pure
millis()state machine; zerodelay()calls; safe to call everyloop()iteration alongside any other code - Universal hardware support — AVR (Uno through Mega), ESP8266, ESP32, STM32 — same source, no
#ifdefin user code - Minimal footprint — two-pump sketch: ~22 KB flash / ~877 B RAM on Uno; ~303 B RAM per additional instance; 23 boolean flags packed into 3 bytes; dOFA adds 5 B SRAM + 2 B EEPROM per instance; tank estimation adds 4 B SRAM + 3 B EEPROM per instance; pool volume and dead-band add 2 bytes SRAM total (shared) and 3 bytes EEPROM;
ConfigData25 bytes, up to 4 instances from EEPROM address 192 - No required dependencies — the library itself needs only
<Arduino.h>and<EEPROM.h>; RTClib (+ Adafruit BusIO) is required only when using an RTC for scheduling — not needed without one
Arduino IDE — Download the repository as a .zip and install via Sketch → Include Library → Add .ZIP Library.
PlatformIO — Copy the library folder into your project's lib/ directory, then add to platformio.ini:
[env:your_board]
platform = atmelavr ; or espressif32, ststm32
board = uno ; your target board
framework = arduino
lib_extra_dirs = lib ; folder containing the APA-DOSING_LIB directory
build_flags =
; -D APA_DOSE_DEBUG ; uncomment for per-cycle diagnostic output on SerialNo additional dependencies — only the standard <Arduino.h> and <EEPROM.h> are required.
APA-Dose controls dosing pumps to keep a pool's pH or ORP (chlorine) at a target setpoint. It reads the current sensor value, calculates how far it is from the target, and runs the pump for a proportional duration — short pulses for small deviations, longer pulses when the water is further off. After each dose it waits for the chemical to mix, reads the sensor again, and verifies the dose was effective before starting the next cycle.
The library is fully non-blocking. All timing uses millis(). Zero delay() calls. Multiple independent pump instances run side-by-side in a single loop().
The user sets a setpoint (target value) and a proportional band (control range). The band defines the sensor range over which the pump output scales from minimum to maximum. Error is calculated as the distance from the setpoint, expressed as a percentage of the band.
pH-PLUS pump example (setpoint 7.4, band 1.0 pH)
Doses when pH falls below setpoint — raises pH toward target.
SP
──────────── Proportional band ───────────────►│
pH ────┼─────────────┬──────────┬─────────┬───────────┼────
6.4 6.65 6.9 7.15 7.4
│ │
100 % 75 % 50 % 25 % threshold
max dose long dose mid dose short dose (idle)
│
ALARM─┘
(safety band)
Pump PWM: pumpMaxPWM ◄────────────────────── min+10% off
Pulse time: 180 s ◄────────────────────── 10 s off
Rest period: 20 min ◄────────────────────── 5 min —
Optional dead-band (setDeadbandPct, default off):
──── active dosing zone ─────────────────────────►│◄─10%─►│
7.30 7.40 (SP)
Suppresses dosing within 10% of band from SP; exits at 5% (hysteresis).
pH-MINUS pump example (setpoint 7.4, band 1.0 pH)
Doses when pH rises above setpoint — lowers pH toward target.
SP
│◄─────────────────── Proportional band ─────────────────
┼───────────┬─────────┬──────────┬───────────────────── pH
7.4 7.65 7.9 8.15 8.4
│ │
threshold 25 % 50 % 75 % 100 %
(idle) short dose mid dose long dose max dose
│
ALARM─┘
(safety band)
Pump PWM: off min+10% ──────────────────────► pumpMaxPWM
Pulse time: — 10 s ──────────────────────► 180 s
Rest period: — 5 min ──────────────────────► 20 min
Optional dead-band (setDeadbandPct, default off):
│◄─10%─►│◄──────────────────── active dosing zone ──────────────
7.40 7.50
(SP) (SP+10%)
Suppresses dosing within 10% of band from SP; exits at 5% (hysteresis).
When a dead-band is configured, the same band width W is mirrored symmetrically on both sides of the setpoint. The dosing zone boundary (SP − W for a raising pump) is also the over-setpoint alarm threshold on the far side (SP + W). With dead-band disabled (W = 0), any persistent over-setpoint reading triggers the alarm.
pH-PLUS pump example (setpoint 7.4, band 1.0, dead-band 10 %)
dead-band width W = 10% × 1.0 = 0.10 pH
don't dose ◄──── W ────►│◄──── W ──── alarm fires after 30 min
7.4
7.30 7.40 7.50
(dead-band entry) SP ALARM_OVER_SP
ALARM_OVER_SETPOINT is non-latching and auto-clears the moment the sensor returns to the dosing zone. No acknowledgeAlarm() call is needed.
The chlorine (ORP) pump follows the pH-PLUS pattern — dosing starts when ORP falls below setpoint.
One direction per pump. Each
ApaDoseinstance controls one chemical direction — either raising pH (PH_PLUS) or lowering it (PH_MINUS). Running both a pH+ and a pH- pump on the same pool at the same time is not supported and will cause the two pumps to fight each other. Choose the direction that matches your water — install only that one pump for pH control.
Every automatic dose passes through six phases:
┌──────────────────────────────────────────────────────────┐
│ │
│ ① SAMPLE BEFORE 2 readings × 30 s apart │
│ │ averaged → before-dose value │
│ ▼ │
│ ② CALCULATE PULSE │
│ │ error % = |setpoint − reading| / band │
│ │ PWM ∝ error % (proportional) │
│ │ time ∝ error % (10 – 180 s) │
│ │ rest ∝ error % (5 – 20 min) │
│ ▼ │
│ ③ RUN PUMP analogWrite(PWM) for pulse time │
│ │ │
│ ▼ │
│ ④ REST chemical mixes into pool water │
│ │ (5 – 20 min, proportional to dose) │
│ ▼ │
│ ⑤ SAMPLE AFTER 3 readings × 30 s apart │
│ │ averaged → after-dose value │
│ ▼ │
│ ⑥ EVALUATE FEEDBACK │
│ │ update EMA delivery baseline │
│ ├─ EMA ratio < threshold? (default 20 %) │
│ │ └──────────────► ALARM_INEFFECTIVE │
│ ├─ direction wrong ×3? ──► ALARM_WRONG_DIRECTION │
│ ├─ sensor moved enough? │
│ │ yes ──► failedAttempts=0; adaptive PB nudge │
│ └─ no ──► failedAttempts++; boost next dose │
│ alarm after 3 (ALARM_INEFFECTIVE) │
└────────────────────────┬─────────────────────────────────┘
│ repeat
▼
Expected timing: A full cycle takes 8 – 26 minutes depending on how far the sensor is from setpoint — pre-sampling alone is 1 minute, pulse is 10 – 180 seconds, rest is 5 – 20 minutes, post-sampling is 1.5 minutes. Add any startup blackout on top. Seeing nothing on Serial for several minutes after boot is normal. Enable
APA_DOSE_DEBUGinplatformio.ini(build_flags = -D APA_DOSE_DEBUG) to print per-cycle progress and confirm the library is running.
OFA accumulation: each proportional dose pulse in phase ③ adds its run time to two independent daily counters. The fixed OFA counter (optional,
setOFALimit()) triggers a warning at 70 % and stops dosing at 90 % of the configured limit. The dynamic OFA counter (dOFA, always active) builds a self-learned baseline and fires when today exceeds 2× normal. Manual doses and shock are excluded from the dOFA counter. See Safety Systems → Over-feed alarm and Dynamic OFA below.
| Error (% of band) | PWM output | Pulse duration | Rest period |
|---|---|---|---|
| 0 – 25 % | proportional | 10 – 30 s | 5 min |
| 25 – 50 % | proportional | 30 – 60 s | 10 min |
| 50 – 75 % | proportional | 60 – 120 s | 15 min |
| 75 – 100 % | pumpMaxPWM | 180 s | 20 min |
A 10 % minimum floor above pumpMinPWM is always applied to overcome pipe resistance.
Most safety features are always active with no configuration required. Two features are opt-in by passing non-zero values to begin(): startup blackout and daily dose limit. The filtration interlock and filter-off notification are only active when a FilterCallback is supplied to begin() — omit it (use the no-filter overload) only when your hardware has no filter pump signal.
| Feature | Behaviour |
|---|---|
| Filtration interlock | Dosing is blocked when the filter pump is off. A running dose stops immediately if the filter cuts out mid-dose. Requires a FilterCallback passed to begin(); inactive when the no-filter overload is used. |
| External stop | An optional callback registered via setExternalStopCallback() can block all dosing from any external system — maintenance mode, backwash cycle, pool cover, or a signal from a filtration controller. A running dose stops the instant the callback returns true. After the signal clears, a mandatory 5-minute settling time (EXTERNAL_STOP_RESUME_MS) must pass before the next dose is allowed — this prevents a brief dose from firing while an operator is still toggling between filtration modes or water is still flowing through a diverted outlet. Priming is exempt. |
| Startup blackout | Optional delay (0 – 60 min) after boot before the first dose. Prevents overdosing after a warm restart when the water chemistry is still in transition. Enabled by passing a non-zero blackoutMinutes to begin(); default is 0 (disabled). |
| Safety band | If the sensor drifts beyond min(band × 1.5, hardCap) from setpoint, ALARM_SAFETY_BAND fires and dosing stops. The check runs every 10 s via the sensor read cycle — not only when a dose is about to start. Clears automatically when the sensor recovers. Hard caps: ±1.0 pH · ±150 mV ORP. |
| Wrong direction detection | If the sensor moves the wrong way on 3 consecutive cycles, ALARM_WRONG_DIRECTION fires. Catches wrong chemical installed or reversed pump wiring before significant harm occurs. |
| Ineffective dose detection | ALARM_INEFFECTIVE fires when the EMA delivery ratio drops below the configured threshold (default 20%, active out of the box), when the sensor shows no response after 3 consecutive dose attempts, or when ORP fails to rise during shock. Catches empty container, blocked tube, or failed pump. |
| Manual dose ceiling | triggerManualDose() clamps duration to 5 minutes regardless of what is passed. Prevents runaway from automation code errors. |
| Chemical tank empty sensor | An optional callback registered via setTankEmptyCallback() fires ALARM_TANK_EMPTY (latching) the moment it returns true — e.g. a float switch or capacitive sensor wired to a dry-contact input. Blocks dosing and priming until the tank is refilled and acknowledgeAlarm() is called. Checked at dose-start time, not continuously, so there is no overhead during the rest period. Zero SRAM cost if unused. |
| Tank level estimation | setTankCapacity(litres) enables software-only tank tracking without any hardware sensor. The library accumulates consumed volume (mL) from every dose and compares it to the configured capacity. ALARM_TANK_EMPTY fires when consumed ≥ capacity. If setTankEmptyCallback() is also registered, the hardware sensor is the sole alarm authority and estimation is suppressed — percentage display still works. acknowledgeAlarm() resets consumed to zero. Consumed counter and capacity are persisted to EEPROM at midnight and survive power cycles. Cost: 4 bytes SRAM + 3 bytes EEPROM per instance. Disabled by default — call setTankCapacity() to enable. |
| Daily dose limit | Optional maximum doses per day. Enabled by passing a non-zero maxDailyDoses to begin(); default is 0 (no limit). ALARM_DAILY_LIMIT fires when reached and clears automatically when the counter resets — at real midnight when an RTC callback is registered, every 24 h from boot without one. No acknowledgment required; dosing resumes on its own the next day. |
| Stale sensor / sensor fault | If the sensor callback returns invalid or out-of-range values continuously for 2 minutes, or returns no valid value at all for 30 minutes, ALARM_SENSOR_FAULT fires and dosing stops. Clears automatically when the sensor recovers — no acknowledgment required. Prevents dosing against a frozen or disconnected sensor. |
| NaN / infinity guard | Every sensor reading is validated before use. A single bad value sends one status message but never corrupts averaging, never triggers a false alarm, and never crashes the state machine. |
| Filter-off notification | If the filter stays off for 30 minutes, a single "Filter off>30min" status message fires. The operator is reminded that circulation has stopped. Active only when a FilterCallback is provided to begin(). |
| Setpoint range enforcement | Both pH and ORP setpoints are rejected if outside safe operating bounds. pH: 6.8 – 7.8 (floor prevents dangerously acidic water; ceiling prevents scaling and chlorine inefficiency). ORP: 400 – 850 mV (floor prevents under-chlorination; ceiling prevents harmful free-chlorine levels for bathers). Out-of-range values are rejected silently and reported via ALARM_INVALID_PARAM. |
| Inter-pump lockout | After any pump instance completes a dose, all other instances wait 90 s before starting. Prevents back-to-back injection of incompatible chemicals at the same inlet (acid + chlorine → chlorine gas). |
| Shock interlock | While triggerShock() is running on a chlorine pump, all other ApaDose instances are held immediately — "Held:shock active" fires once per held instance. When shock ends all instances resume automatically. This prevents pH acid from being dosed into a high-ORP pool mid-shock. |
| Post-shock safety band suppression | After shock completes, the safety band alarm is suppressed for the cooldown window (default 24 h, max 48 h) while elevated ORP normalizes. Without suppression, the expected post-shock ORP level would trigger a false ALARM_SAFETY_BAND within minutes of stopping. "Post-shock normal" fires when the window expires. |
| Over-setpoint protection | If the sensor stays on the wrong side of setpoint for more than 30 minutes, ALARM_OVER_SETPOINT fires. The threshold mirrors the dead-band width on the opposite side of the setpoint — the same W that defines the dosing entry boundary. With dead-band disabled (W = 0) any persistent over-setpoint triggers it. Auto-clears when the reading returns to the dosing zone — no ACK needed. Cost: 4 bytes SRAM per instance. |
Alarms stop the pump immediately. Each alarm is reported through the onAlarmTriggered callback and can also be polled at any time.
| Alarm | Trigger | Recovery |
|---|---|---|
ALARM_WRONG_DIRECTION |
Sensor moved wrong way 3× in a row | Fix chemical or wiring → acknowledgeAlarm() |
ALARM_INEFFECTIVE |
EMA delivery ratio below threshold, no sensor response after 3 doses, or no ORP rise during shock | Fix pump or supply → acknowledgeAlarm() |
ALARM_SAFETY_BAND |
Sensor beyond safety band | Automatic when sensor returns to safe range |
ALARM_DAILY_LIMIT |
Max daily doses reached | Automatic at midnight (RTC) or after 24 h (millis) — no acknowledgment needed |
ALARM_SENSOR_FAULT |
Invalid/out-of-range readings for 2 min, or no valid reading for 30 min | Automatic when sensor recovers — no acknowledgment needed |
ALARM_TANK_EMPTY |
Tank empty callback returned true at dose-start time |
Refill tank → acknowledgeAlarm() |
ALARM_OFA |
Daily proportional run-time reached 90 % of the setOFALimit() ceiling or 2× the dOFA learned baseline |
acknowledgeAlarm() — resets both daily counters immediately; counters also reset automatically at midnight |
ALARM_OVER_SETPOINT |
Sensor on wrong side of setpoint for >30 min | Automatic when sensor returns to dosing zone — no ACK needed |
ALARM_INVALID_PARAM |
Bad configuration value | Rejected silently — no alarm stays active |
Register an alarm callback before calling begin():
void onAlarm(ApaDoseAlarm alarm, const char* message) {
// message is max 19 chars — fits one row of a 16×2 LCD
Serial.println(message);
digitalWrite(PIN_BUZZER, HIGH);
}
void onAlarmCleared(ApaDoseAlarm alarm, const char* message) {
digitalWrite(PIN_BUZZER, LOW);
}
void setup() {
phPump.setCallbacks(onAlarm, onAlarmCleared, onStatus);
phPump.begin(getpH, filterRunning, DOSE_PH, PH_MINUS, 20);
}Alarm state can also be polled without callbacks — useful for display code:
void loop() {
phPump.update();
if (phPump.isAlarmActive()) {
lcd.print(phPump.getAlarmMessage()); // same text as the callback
if (buttonPressed())
phPump.acknowledgeAlarm();
}
}See
examples/advanced/06_alarm_management/for a complete alarm UI with acknowledge button — documents bothALARM_INEFFECTIVEtrigger paths (EMA efficiency and 3-strike) and showsgetDoseEffectiveness()alongside the alarm state for every periodic status print.
Seeexamples/intermediate/03_serial_diagnostics/for interactive serial diagnostics including per-dose delivery health output:getDoseEffectiveness(),getLastDoseSensorBefore()/getLastDoseSensorAfter(), and EMA warm-up state.
Seeexamples/expert/07_apa_serial/for a serial interface with full alarm reporting.
On first boot (blank EEPROM) the library starts with these built-in defaults. They are saved to EEPROM and survive all subsequent power cycles until changed via setProbeSetpoint() / setProportionalBand().
| Parameter | pH default | ORP / CL default |
|---|---|---|
| Setpoint | 7.4 | 700 mV |
| Band | 1.0 pH | 100 mV |
Hardware note:
PIN_PH_PUMPmust be a PWM-capable pin (marked~on Arduino boards). Connect the pump through an N-channel MOSFET or a relay module — the library drives it withanalogWrite(). A plain relay works too; setsetPumpRange(255, 255)for on/off mode.
#include <APADOSE.h>
ApaDose phPump(PIN_PH_PUMP); // must be a PWM-capable pin (~)
// Replace with your pH sensor library's read call.
// Must return a float. Called by the library every ~30 s during sampling.
float getpH() { return phSensor.getPH(); }
bool filterRunning() { return digitalRead(PIN_FILTER_RELAY) == HIGH; }
void onAlarm(ApaDoseAlarm alarm, const char* msg) {
Serial.println(msg);
}
void setup() {
phPump.setPumpRange(65, 255); // 65 = PWM where YOUR pump starts — measure it
phPump.setCallbacks(onAlarm);
// PH_MINUS = acid pump (lowers pH); use PH_PLUS for a base pump (raises pH)
// 20 min startup blackout · max 6 doses/day (pass 0 for either to disable)
// Normal on first install — returns false only when no valid config exists yet or EEPROM is corrupt
if (!phPump.begin(getpH, filterRunning, DOSE_PH, PH_MINUS, 20, 6))
Serial.println("No saved config — defaults loaded");
}
void loop() {
// update() is non-blocking — call every iteration; tolerates occasional ms-level loop delays
phPump.update();
}Temperature compensation: pH sensor readings are temperature-dependent (~0.003 pH/°C). The APAPHX and APAPHX2 libraries apply the Passco 2001 formula to deliver a stable, temperature-compensated value automatically. If using a different sensor library, apply temperature compensation inside your
getSensorValue()callback so APA-Dose always receives a corrected reading.
#include <APADOSE.h>
ApaDose phPump(PIN_PH_PUMP);
ApaDose clPump(PIN_CL_PUMP, APA_DOSE_EEPROM_ADDRESS + sizeof(ConfigData)); // each instance uses sizeof(ConfigData) bytes
float getpH() { return phSensor.getPH(); }
float getORP() { return orpSensor.getORP(); }
bool filterRunning() { return digitalRead(PIN_FILTER_RELAY) == HIGH; }
void onAlarm(ApaDoseAlarm alarm, const char* msg) { Serial.println(msg); }
void setup() {
phPump.setPumpRange(65, 255);
phPump.setCallbacks(onAlarm);
// PH_MINUS = acid pump (lowers pH); use PH_PLUS for a base pump (raises pH)
if (!phPump.begin(getpH, filterRunning, DOSE_PH, PH_MINUS, 20))
Serial.println("No saved config — defaults loaded");
clPump.setPumpRange(65, 255);
clPump.setCallbacks(onAlarm);
if (!clPump.begin(getORP, filterRunning, DOSE_CL, CL_PLUS, 20))
Serial.println("No saved config — defaults loaded");
}
void loop() {
phPump.update();
clPump.update();
}Two pumps: After one pump doses, the other waits 90 seconds before it can start — see Inter-pump lockout below.
Each ApaDose instance is a fully independent state machine with its own dosing cycle, feedback loop, alarm state, and EEPROM block. Instances never interfere with each other.
#include <APADOSE.h>
ApaDose phPump (PIN_PH, APA_DOSE_EEPROM_ADDRESS);
ApaDose clPump (PIN_CL, APA_DOSE_EEPROM_ADDRESS + sizeof(ConfigData)); // EEPROM 212
ApaDose flocPump(PIN_FLOC, APA_DOSE_EEPROM_ADDRESS + 2 * sizeof(ConfigData)); // EEPROM 232
ApaDose algiPump(PIN_ALGI, APA_DOSE_EEPROM_ADDRESS + 3 * sizeof(ConfigData)); // EEPROM 252
void loop() {
phPump.update();
clPump.update();
flocPump.update(); // sensor-less — manual / scheduled dosing only
algiPump.update(); // sensor-less — manual / scheduled dosing only
}Each pump's EEPROM configuration block is 20 bytes (sizeof(ConfigData)). The four-pump layout above occupies addresses 192 – 271, safely clear of the APAPHX2 sensor library (128 – 177).
Inter-pump lockout: After any pump instance completes a dose, all other instances wait 90 seconds before starting their next dose. This prevents acid and chlorine from being injected back-to-back at the same pipe inlet — mixing them there produces chlorine gas. If your second pump seems slow to react after the first one doses, this is why — it is working as intended.
Pumps with no sensor are initialised by passing nullptr as the sensor callback. Proportional dosing never fires — control these pumps entirely with triggerManualDose():
// type and dir are required by the API signature but have no effect here:
// proportional dosing never runs without a sensor, so the library never consults them.
// Any valid combination compiles and works — DOSE_PH / PH_PLUS is the conventional placeholder.
flocPump.begin(nullptr, filterRunning, DOSE_PH, PH_PLUS, 0, 1); // no sensor · no blackout · max 1 dose/day
algiPump.begin(nullptr, filterRunning, DOSE_PH, PH_PLUS, 0, 1);The filtration interlock and daily dose limit remain active even without a sensor.
triggerManualDose(durationMs, restMs) triggers a single dose at pumpMaxPWM. Duration is in milliseconds and is clamped to 5 minutes — requests above this ceiling are silently reduced and a "Dose capped:5min" status message is sent. The optional restMs (default 20 min) sets the mixing wait before the next proportional cycle resumes. Each call increments the daily dose counter.
// Button-triggered dose — safe to call every loop(); returns false immediately if blocked
if (buttonPressed())
flocPump.triggerManualDose(30UL * 1000UL); // 30 s at pumpMaxPWM
// RTC-scheduled weekly dose — call once when the condition is met
if (t.weekday == 1 && t.hour == 8 && !weeklyFlocDone) {
if (flocPump.triggerManualDose(60UL * 1000UL)) // 60 s at pumpMaxPWM; returns false if blocked
weeklyFlocDone = true;
}triggerManualDose() returns false and does nothing when: a dose or prime is already running, an alarm is active, the filter is off (when a FilterCallback is provided), the external stop callback returns true or its 5-minute resume delay is still active, the daily dose limit is reached, or the 90-second inter-pump lockout is still in effect.
setScheduledDose(hour, minute, durationMs) arms a dose that fires automatically at the configured wall-clock time — no tracking code in loop() required. An RTC callback must be registered via setRTCCallback() for the schedule to work.
// Algaecide every day at 09:00 for 30 s (sensor-less — threshold ignored, always doses)
algiPump.setScheduledDose(9, 0, 30UL * 1000UL);
// Flocculant every 7 days at 08:30 for 60 s (sensor-less — threshold ignored, always doses)
flocPump.setScheduledDose(8, 30, 60UL * 1000UL, 7);
// pH acid daily at 07:00 — doses only if pH has drifted past setpoint (default threshold = setpoint)
phPump.setScheduledDose(7, 0, 10UL * 1000UL);
// pH acid daily — explicit threshold: dose only if pH > 7.4 regardless of setpoint
phPump.setScheduledDose(7, 0, 10UL * 1000UL, 1, 7.4f);
// pH acid daily — always dose at 07:00 no matter what the sensor reads
phPump.setScheduledDose(7, 0, 10UL * 1000UL, 1, 0.0f);Optional parameters:
| # | Name | Default | Effect |
|---|---|---|---|
| 4 | intervalDays |
1 |
Repeat every N days. 7 = weekly, 14 = fortnightly. |
| 5 | threshold |
setpoint | Skip dose if sensor is already in the safe direction. Default NAN = use the pump's own setpoint. 0.0 = always dose. Any finite value = explicit override. Ignored for sensor-less pumps. |
The threshold direction is resolved automatically: for a PH_MINUS pump, 7.4 means "skip if pH ≤ 7.4"; for a PH_PLUS or DOSE_CL pump, 7.4 means "skip if pH ≥ 7.4". No direction parameter needed.
All standard safety guards apply — filtration interlock, external stop, tank-empty check, daily dose limit, inter-pump lockout, and active alarm block all prevent the dose from firing, exactly as for triggerManualDose(). The scheduled dose is most useful for sensor-less pumps (algaecide, flocculant) where proportional control does not apply.
triggerPrime(durationMs, pwm) runs the pump for exactly durationMs milliseconds to fill a dry pipe after first installation or a chemical container swap. pwm is optional — pass 0 (default) to use pumpMaxPWM, or any value between pumpMinPWM and pumpMaxPWM for a slower fill.
Priming bypasses all safety guards — no filter check, no alarm state, no daily dose limit, no inter-pump lockout. Use it only during maintenance, never from automated scheduling code.
// Fill the pipe after swapping the acid container — 20 s at full speed
phPump.triggerPrime(20UL * 1000UL);
// Slower prime if pipe or fittings are fragile
phPump.triggerPrime(20UL * 1000UL, 160); // 160 / 255 PWMA "Prime done" status message fires when the duration elapses. Priming does not count toward the daily dose limit. No rest period is imposed after priming — priming fills dry pipe only and carries no chemical into the pool, so consecutive primes and immediate automatic dosing are both permitted.
triggerPrime() returns false if a dose or another prime is already running.
See
examples/advanced/05_multi_pump/for the complete four-pump example.
triggerShock() doses the chlorine pump at full power until ORP reaches a target value or a time ceiling expires, then automatically returns to normal proportional control. Use it after heavy bather load, algae events, storms, or any situation where the pool is so far from target that proportional dosing would take many hours.
Two overloads:
// Hobbyist — use named presets, sensible defaults (4 h max, 24 h cooldown)
clPump.triggerShock(SHOCK_ORP_STANDARD, phPump.getProbeValue());
// Pro — full control
clPump.triggerShock(780, 3, phPump.getProbeValue(), 48);
// ^targetORP ^maxHours ^currentPH ^cooldownHoursNamed ORP presets (use instead of raw mV values):
| Constant | Value | When |
|---|---|---|
SHOCK_ORP_MILD |
700 mV | Post-rain, minor algae risk |
SHOCK_ORP_STANDARD |
750 mV | Weekly maintenance shock |
SHOCK_ORP_AGGRESSIVE |
800 mV | Heavy algae, high bather load |
Key behaviours:
- pH must be 7.0–7.6 before shock — chlorine is far more effective in this range (above 7.6, over half the free chlorine converts to the inactive OCl⁻ form)
- Stops 10% before the target ORP — compensates for chlorine mixing lag; ORP continues rising after dosing stops
- At 20 minutes: checks that ORP has risen ≥ 20 mV; if not, aborts with
ALARM_INEFFECTIVE— catches an empty tank or failed pump before wasting hours - All other pumps are held during shock —
"Held:shock active"fires; they resume automatically when shock ends - Safety band alarm suppressed for 24 h after shock (configurable up to 48 h) — prevents false
ALARM_SAFETY_BANDwhile ORP normalizes - With RTC: post-shock cooldown is wall-clock based and survives power cycles
dailyDoseCountis not incremented;dailyVolumeMlis accumulated for cost tracking
// In loop() — check shock state for display or logging
if (clPump.isShockActive()) {
Serial.print("Shock active, ");
Serial.print(clPump.getShockRemainingSeconds() / 60);
Serial.println(" min remaining");
}See
examples/basic/02_ph_and_cl/for the hobbyist shock call with a dedicated button.
Seeexamples/advanced/05_multi_pump/for the pro call with RTC-backed cooldown and serial feedback.
Full entry guards, chemistry reference, and alarm table:docs/API.md.
Call setup methods in this order — order matters on first boot:
1. setPumpRange() calibrate motor dead band (optional, default 50–255)
set min == max (e.g. 255, 255) for solenoid valves — see below
2. setPumpFlowRate() optional — pump output at max PWM in mL/min (default 450)
enables getDailyVolumeMl() / getLastDoseVolumeMl() volume tracking
3. setRTCCallback() optional — enables time-based scheduling
4. setDosingWindow() optional — restrict dosing to specific hours
5. setExternalStopCallback() optional — block dosing from external conditions (maintenance, backwash…)
6. setCallbacks() register alarm/status callbacks BEFORE begin()
7. begin() connect sensor, set type + direction, load EEPROM, start library
returns false if EEPROM data was corrupt — defaults are used, safe to continue
8. ApaDose::setPoolVolume() optional — system-wide pool size scaling (static, affects all instances)
ApaDose::setDeadbandPct() optional — system-wide dosing dead-band (static, affects all instances)
both must come AFTER begin() — EEPROM must be initialised first
can also be called at runtime from loop() — takes effect immediately
Two overloads are available. Use the full form when a filter pump signal is wired; use the no-filter shorthand when it is not:
// Full form — filtration interlock and filter-off notification active
pump.begin(sensorReader, filter, type, dir, blackoutMinutes, maxDailyDoses);
// No-filter shorthand — interlock disabled
pump.begin(sensorReader, type, dir, blackoutMinutes, maxDailyDoses);| # | Parameter | Type | Required | Default | Description |
|---|---|---|---|---|---|
| 1 | sensorReader |
SensorReadCallback |
Yes | — | Callback returning the current sensor value as float. Pass nullptr for sensor-less pumps (flocculant, algaecide) — proportional dosing is then disabled. |
| 2 | filter |
FilterCallback |
No | (omitted) | Callback returning true when the filter pump is running. Present only in the full form; omit it by using the no-filter overload. |
| 3 | type |
ApaDoseType |
Yes | — | Chemical type: DOSE_PH (pH pump) or DOSE_CL (chlorine/ORP pump). Always overwrites the EEPROM-stored value — hardware type is authoritative. |
| 4 | dir |
ApaDoseDirection |
Yes | — | pH dosing direction: PH_MINUS (acid, lowers pH) or PH_PLUS (base, raises pH). For DOSE_CL pumps use CL_PLUS — a named alias for PH_PLUS that makes intent clear; direction is not used in chlorine control logic. |
| 5 | blackoutMinutes |
uint8_t |
No | 0 |
Startup delay before the first dose, in minutes. Accepted range 0 – 60; 0 disables the blackout. |
| 6 | maxDailyDoses |
uint8_t |
No | 0 |
Maximum combined automatic and manual doses per day. 0 = no limit. |
Parameters 5 and 6 are positional and must be supplied in order when used. Trailing defaults can be omitted — pump.begin(getSensor, filterRunning, DOSE_PH, PH_MINUS, 20) sets a 20-minute blackout and leaves the daily limit unrestricted.
Sensor smoothing required. The
SensorReadCallbackmust return a pre-smoothed value. APA-Dose averages 2 readings before each dose and 3 readings after, but each individual sample is used as received. High reading-to-reading deviation — caused by electrical noise, unstable sensor electronics, or missing RC filtering on the ADC input — leads to incorrect proportional pulse calculation, wrong-direction detection false positives, and prematureALARM_SENSOR_FAULTorALARM_WRONG_DIRECTIONtriggers. Apply a moving average, exponential smoothing (IIR), or median filter inside the callback before returning. The APAPHX-Board v2 hardware delivers inherently stable, high-resolution readings through its dedicated ADC and signal conditioning circuit, making software smoothing largely unnecessary when using that board; bare ADC solutions wired directly to a microcontroller analog input typically require it.
EEPROM writes happen on every call to setProbeSetpoint(), setProportionalBand(), or setDosingType(), and once at begin() if no valid config was found. On AVR, EEPROM.put() uses EEPROM.update() internally and skips bytes that already match, limiting wear. On ESP8266/ESP32, EEPROM.commit() is called per save — flash writes are more expensive; call forceConfigurationSave() after a batch of changes to guarantee persistence before the next power cycle rather than calling setters one at a time in a configuration flow.
Diagnostic output — define APA_DOSE_DEBUG before building to print per-cycle messages on Serial (sensor readings, PWM value, pulse duration, feedback result).
PlatformIO — add to platformio.ini:
build_flags = -D APA_DOSE_DEBUGArduino IDE — open APADOSE.h in the library folder and uncomment the prepared line near the top of the file:
// #define APA_DOSE_DEBUG ← remove the leading // to enableRemember to comment it back out before releasing production firmware — the define adds ~200 bytes of flash and runtime snprintf overhead on every dosing cycle.
setPumpRange(minPWM, maxPWM) with min < max — both PWM speed and pulse duration scale with error. This is the primary use case.
Peristaltic pump calibration — two steps:
Step 1 — PWM start threshold (required): Run
examples/calibration/00_pump_calibration/before writing your main sketch. It is an interactive Serial utility: type a PWM value, the pump runs for 3 seconds, repeat until the shaft turns — then typedoneand it prints the exactsetPumpRange()line to copy. Run it once per pump; the threshold is specific to each motor and supply voltage.Step 2 — Flow rate (optional, for volume tracking): Run
examples/calibration/01_flow_rate_calibration/to measure your pump's output at max PWM in mL/min. Fill the container with 500 mL of the actual chemical, typerun— the pump starts. Typestopwhen the container empties. The sketch prints the exactsetPumpFlowRate()line to copy. Once set,getDailyVolumeMl()andgetLastDoseVolumeMl()return accurate consumption figures.
Solenoids are binary devices and cannot be speed-controlled by PWM. Set min == max to lock PWM at a fixed level and let pulse duration carry all the proportionality:
pump.setPumpRange(255, 255); // solenoid: always full-on; time varies with errorWith min == max, the dosing zones table still applies — the solenoid opens for 10–180 s proportional to the error percentage. Feedback, safety, and alarm systems work identically.
Call setPumpFlowRate() with your pump's measured output at max PWM to enable chemical consumption monitoring:
Measuring your pump's flow rate: Run
examples/calibration/01_flow_rate_calibration/to measure this value. Fill the container with exactly 500 mL of the actual chemical, typerun— the pump starts at full speed. Typestopthe moment the container empties. The sketch prints the flow rate and the exactsetPumpFlowRate()line to copy. Run once per pump using the real chemical — water has a different viscosity and gives inaccurate results.
phPump.setPumpFlowRate(420.0); // measured 420 mL/min for this specific pumpThe default is 450 mL/min if not called. Volume per dose is estimated from actual pulse duration and PWM intensity relative to maximum. Read back with:
Serial.print(phPump.getDailyVolumeMl(), 1); // total mL dosed today
Serial.print(phPump.getLastDoseVolumeMl(), 1); // mL in the last dose
Serial.print(phPump.getDailyDoseCount()); // number of doses todaygetDailyVolumeMl() resets at midnight when an RTC callback is registered, in sync with getDailyDoseCount(). Without an RTC both counters reset every 24 h from boot.
Call enableAdaptivePB(nudgePct) after begin() to switch from a fixed control band to a self-learning one. After each successful feedback cycle the library nudges the effective band toward the pool's true chemical response — wider when the sensor overshot, narrower when it undershot.
phPump.enableAdaptivePB(5); // learn at 5% per feedback cycle (1–25 allowed)
// Read back the current effective band at any time:
Serial.print(F("Band: "));
Serial.println(phPump.getAdaptedPB()); // learned or fixed, whichever is active| Call | Behaviour |
|---|---|
enableAdaptivePB(5) |
Enable at 5% nudge rate; seeds from fixed band on first enable |
enableAdaptivePB(0) |
Disable; learned value is discarded; reverts to fixed band |
getAdaptedPB() |
Current effective band (learned when on, fixed when off) |
isAdaptivePBEnabled() |
true when nudgePct > 0 |
The learned value is saved to EEPROM after every adjustment and restored on reboot. It is per-instance — each pump learns independently.
Full API reference: docs/API.md
| Platform | Tested boards | Notes |
|---|---|---|
| AVR | Uno, Mega, Nano, Pro Mini | Full support; fits on Uno (32 KB flash / 2 KB SRAM) |
| ESP8266 | NodeMCU, Wemos D1 Mini | EEPROM begin() / commit() handled automatically |
| ESP32 | ESP32-DevKit, S2, S3, C3 | EEPROM flash emulation handled automatically |
| STM32 | Nucleo, Blue Pill (STM32duino) | Verified on Blue Pill F103C8 (STM32duino) |
Verified build sizes (examples/basic/02_ph_and_cl — two-pump sketch, release mode, clean build):
| Board | Flash | RAM |
|---|---|---|
| Arduino Uno (ATmega328P) | 22,424 B / 32,256 B (70%) | 877 B / 2,048 B (43%) |
| Arduino Mega 2560 | 23,476 B / 253,952 B (9%) | 877 B / 8,192 B (11%) |
| ESP32-DevKit | 296,533 B / 1,310,720 B (23%) | 22,208 B / 327,680 B (7%) |
| NodeMCU v2 (ESP8266) | 282,747 B / 1,044,464 B (27%) | 28,968 B / 81,920 B (35%) |
| Blue Pill (STM32F103C8T6) | 32,424 B / 65,536 B (49%) | 2,764 B / 20,480 B (14%) |
ESP flash totals include the full Arduino framework (WiFi stack, OS); the library itself adds a few KB on top of a bare sketch.
Dependencies: <Arduino.h> and <EEPROM.h> only.
No APA libraries. No sensor libraries. No communication libraries. No other third-party code.
APA-Dose works standalone with any sensor that returns a float. For a complete household pool automation solution, it pairs with dedicated APA Devices hardware and libraries to cover every layer of the control stack.
Professional pool controllers cost hundreds to thousands of euros, lock you into a closed system, and offer no visibility into what they actually do. The APA Devices ecosystem gives you the same closed-loop proportional dosing, safety interlocks, and alarm logic on hardware you own and software you can read — for a fraction of the price.
Everything you need, layer by layer:
| Layer | Component | What it provides |
|---|---|---|
| Sensor hardware | APAPHX-Board v2 | Ready-made dual-channel pH + ORP measurement board based on ADS1115; I²C, calibrated, temperature-compensated |
| Sensor firmware | APAPHX2_ADS1115 | Non-blocking driver for the APAPHX-Board v2; applies Passco 2001 formula for stable temperature-compensated readings; returns a single float |
| Dosing control | APA-Dose (this library) | Proportional pump control, closed-loop feedback, full alarm system, safety interlocks — all the intelligence |
| Temperature | DS2482 | DS2482-800 I²C-to-1-Wire bridge for DS18B20 water temperature sensors; or use any DS18B20 library or raw sensor of your choice |
| Microcontroller | Your choice | Any Arduino-compatible board — Uno, Mega, ESP32, STM32 |
Pair an APAPHX-Board v2 with the APAPHX2_ADS1115 library and this APA-Dose library, add a microcontroller, wire your dosing pumps — and you have a complete, calibrated, safe pool dosing system. Temperature monitoring is optional but straightforward with the DS2482 library or any DS18B20 solution you already have.
No subscriptions. No cloud dependency. No proprietary protocol. Full source code. You control everything.
See
examples/expert/for complete sketches combining all three libraries.
APA-DOSING_LIB/
├── src/
│ ├── APADOSE.h Main header — all public types and API
│ └── APADOSE.cpp Implementation
├── docs/
│ └── API.md Full API reference
├── examples/
│ ├── calibration/ 00_pump_calibration (run first — finds setPumpRange() value)
│ │ 01_flow_rate_calibration (optional — measures mL/min for volume tracking)
│ ├── basic/ 01_single_ph · 02_ph_and_cl (includes shock button)
│ ├── intermediate/ 03_serial_diagnostics · 04_lcd_display
│ ├── advanced/ 05_multi_pump (RTC + shock pro API) · 06_alarm_management
│ └── expert/ 07–10 combined APAPHX2 + DS2482 sketches
├── extras/
│ └── apadose-banner.png Repository banner image
├── LICENSE
├── keywords.txt
├── library.properties
└── CHANGELOG.md
This library was developed for private household pool automation only.
It is not designed, tested, or approved for commercial pools, public swimming facilities, spas, or any installation subject to health and safety regulation.
The library is provided as-is, without warranty of any kind, and without any support.
Chemical dosing systems carry real safety risks. Incorrect configuration, wiring errors, or sensor failure can result in severe over-dosing that may harm bathers, damage equipment, or create hazardous conditions. The user assumes full responsibility for installation, configuration, safe operation, and compliance with all applicable local regulations.
APA-Dose is released under a dual license:
Non-commercial use — free
Personal, private, educational, and hobby use is permitted free of charge. See the LICENSE file for full terms.
Commercial use — license required
Using this library in commercial products or services is strictly prohibited without a separate written Commercial License. This includes — but is not limited to:
- Selling hardware with APA-Dose pre-installed or bundled
- Commercial pool maintenance or chemical dosing services that rely on this library
- Integrating this library into products or systems sold to third parties
For commercial licensing, custom integration, volume pricing, or OEM arrangements, please contact:
Author: kecup@vazac.eu
© APA Devices
