Skip to content

Commit 983b382

Browse files
authored
Merge pull request #114 from SenaxInc/copilot/add-4ma-20ma-sensor-options
Add 4mA-20mA sensor type options (pressure vs ultrasonic)
2 parents 20ee469 + 08789b9 commit 983b382

File tree

3 files changed

+313
-2
lines changed

3 files changed

+313
-2
lines changed

TankAlarm-112025-Client-BluesOpta/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,70 @@ The client creates a default configuration on first boot. You can update configu
8989
- **Min Inches**: Tank level in inches at minimum sensor value
9090
- **Max Inches**: Tank level in inches at maximum sensor value
9191

92+
### 4-20mA Current Loop Sensor Configuration
93+
94+
For 4-20mA current loop sensors, two mounting options are supported:
95+
96+
#### Pressure Sensor (Bottom-Mounted)
97+
Used for sensors like the Dwyer 626-06-CB-P1-E5-S1 (0-5 PSI) mounted near the bottom of the tank.
98+
99+
- **Current Loop Type**: "pressure"
100+
- **How it works**: Measures the pressure of the liquid column above the sensor
101+
- 4mA = Empty tank (0 PSI / no liquid above sensor)
102+
- 20mA = Full tank (max PSI / max liquid height)
103+
- **Sensor Range**: The native measurement range (e.g., 0-5 PSI, 0-2 bar)
104+
- `sensorRangeMin`: Minimum value (typically 0)
105+
- `sensorRangeMax`: Maximum value (e.g., 5 for 0-5 PSI)
106+
- `sensorRangeUnit`: Unit of measurement ("PSI", "bar", etc.)
107+
- **Sensor Mount Height**: Height of sensor above tank bottom (usually 0-2 inches)
108+
- **Max Value**: Maximum liquid height the sensor can measure (corresponds to 20mA)
109+
110+
**Example Configuration** (0-5 PSI sensor, 1 PSI per 2.3 ft of water):
111+
- Sensor mounted 2 inches above tank bottom
112+
- Max sensor range = 5 PSI = ~138 inches of water
113+
- Tank height = 120 inches
114+
- Configuration:
115+
- `currentLoopType`: "pressure"
116+
- `sensorRangeMin`: 0
117+
- `sensorRangeMax`: 5
118+
- `sensorRangeUnit`: "PSI"
119+
- `sensorMountHeight`: 2.0
120+
- `maxValue`: 118.0 (tank height minus mount height: 120 - 2 = 118 inches)
121+
122+
> **Note:** For pressure sensors, set `maxValue` to the tank's usable height minus the sensor mount height. The implementation adds `sensorMountHeight` to the measured value, so `maxValue` should represent the height of liquid *above* the sensor, not the total tank height.
123+
124+
#### Ultrasonic Sensor (Top-Mounted)
125+
Used for sensors like the Siemens Sitrans LU240 mounted on top of the tank looking down.
126+
127+
- **Current Loop Type**: "ultrasonic"
128+
- **How it works**: Measures the distance from the sensor to the liquid surface
129+
- 4mA = Full tank (liquid close to sensor)
130+
- 20mA = Empty tank (liquid far from sensor)
131+
- **Sensor Range**: The native measurement range (e.g., 0-10 meters, 0-30 feet)
132+
- `sensorRangeMin`: Minimum distance (typically 0)
133+
- `sensorRangeMax`: Maximum distance (e.g., 10 for 0-10m)
134+
- `sensorRangeUnit`: Unit of measurement ("m", "ft", "in", etc.)
135+
- **Sensor Mount Height**: Distance from sensor to tank bottom when tank is empty
136+
- **Max Value**: Maximum liquid height (tank capacity)
137+
138+
**Example Configuration** (ultrasonic sensor with 0-10m range on 10-foot tank):
139+
- Sensor mounted 124 inches above tank bottom (tank is 120 inches + 4 inch clearance)
140+
- Maximum tank fill level = 120 inches
141+
- Configuration:
142+
- `currentLoopType`: "ultrasonic"
143+
- `sensorRangeMin`: 0
144+
- `sensorRangeMax`: 10
145+
- `sensorRangeUnit`: "m"
146+
- `sensorMountHeight`: 124.0
147+
- `maxValue`: 120.0
148+
149+
**Calibration Tips for 4-20mA Sensors:**
150+
1. Record the actual mA output at known liquid levels (empty, half-full, full)
151+
2. Verify sensor mount height is accurate
152+
3. Enter the correct sensor native range (as specified in sensor datasheet)
153+
4. Check for temperature effects on readings
154+
5. Consider the specific gravity of the liquid (for pressure sensors)
155+
92156
### Float Switch Configuration (Digital Sensors)
93157
Float switches can be configured as either normally-open (NO) or normally-closed (NC):
94158

TankAlarm-112025-Client-BluesOpta/TankAlarm-112025-Client-BluesOpta.ino

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,14 @@ enum SensorType : uint8_t {
210210
SENSOR_HALL_EFFECT_RPM = 3
211211
};
212212

213+
// 4-20mA current loop sensor subtypes
214+
enum CurrentLoopSensorType : uint8_t {
215+
CURRENT_LOOP_PRESSURE = 0, // Pressure sensor mounted near bottom of tank (e.g., Dwyer 626-06-CB-P1-E5-S1)
216+
// 4mA = empty (0 PSI), 20mA = full (max PSI)
217+
CURRENT_LOOP_ULTRASONIC = 1 // Ultrasonic sensor mounted on top of tank (e.g., Siemens Sitrans LU240)
218+
// 4mA = full (sensor close to liquid), 20mA = empty (sensor far from liquid)
219+
};
220+
213221
// Relay trigger conditions - which alarm type triggers the relay
214222
enum RelayTrigger : uint8_t {
215223
RELAY_TRIGGER_ANY = 0, // Trigger on any alarm (high or low)
@@ -251,6 +259,13 @@ struct TankConfig {
251259
// Digital sensor (float switch) specific settings
252260
char digitalTrigger[16]; // 'activated' or 'not_activated' - when to trigger alarm for digital sensors
253261
char digitalSwitchMode[4]; // 'NO' for normally-open, 'NC' for normally-closed (default: NO)
262+
// 4-20mA current loop sensor settings
263+
CurrentLoopSensorType currentLoopType; // Pressure (bottom-mounted) or Ultrasonic (top-mounted)
264+
float sensorMountHeight; // For ultrasonic: distance from sensor to tank bottom (inches)
265+
// For pressure: height of sensor above tank bottom (inches, usually 0-2)
266+
float sensorRangeMin; // Minimum native sensor range (e.g., 0 for 0-5 PSI or 0-10m)
267+
float sensorRangeMax; // Maximum native sensor range (e.g., 5 for 0-5 PSI, 10 for 0-10m)
268+
char sensorRangeUnit[8]; // Unit for sensor range: "PSI", "bar", "m", "ft", "in", etc.
254269
};
255270

256271
struct ClientConfig {
@@ -633,6 +648,11 @@ static void createDefaultConfig(ClientConfig &cfg) {
633648
cfg.tanks[0].relayMode = RELAY_MODE_MOMENTARY; // Default: momentary 30 min activation
634649
cfg.tanks[0].digitalTrigger[0] = '\0'; // Not a digital sensor by default
635650
strlcpy(cfg.tanks[0].digitalSwitchMode, "NO", sizeof(cfg.tanks[0].digitalSwitchMode)); // Default: normally-open
651+
cfg.tanks[0].currentLoopType = CURRENT_LOOP_PRESSURE; // Default: pressure sensor (most common)
652+
cfg.tanks[0].sensorMountHeight = 0.0f; // Default: sensor at tank bottom
653+
cfg.tanks[0].sensorRangeMin = 0.0f; // Default: 0 (e.g., 0 PSI or 0 meters)
654+
cfg.tanks[0].sensorRangeMax = 5.0f; // Default: 5 (e.g., 5 PSI for typical pressure sensor)
655+
strlcpy(cfg.tanks[0].sensorRangeUnit, "PSI", sizeof(cfg.tanks[0].sensorRangeUnit)); // Default: PSI
636656
}
637657

638658
static bool loadConfigFromFlash(ClientConfig &cfg) {
@@ -768,6 +788,20 @@ static bool loadConfigFromFlash(ClientConfig &cfg) {
768788
} else {
769789
strlcpy(cfg.tanks[i].digitalSwitchMode, "NO", sizeof(cfg.tanks[i].digitalSwitchMode)); // Default: normally-open
770790
}
791+
// Load 4-20mA current loop sensor type (pressure or ultrasonic)
792+
const char *currentLoopTypeStr = t["currentLoopType"].as<const char *>();
793+
if (currentLoopTypeStr && strcmp(currentLoopTypeStr, "ultrasonic") == 0) {
794+
cfg.tanks[i].currentLoopType = CURRENT_LOOP_ULTRASONIC;
795+
} else {
796+
cfg.tanks[i].currentLoopType = CURRENT_LOOP_PRESSURE; // Default: pressure sensor
797+
}
798+
// Load sensor mount height (for calibration) - validate non-negative
799+
cfg.tanks[i].sensorMountHeight = t["sensorMountHeight"].is<float>() ? fmaxf(0.0f, t["sensorMountHeight"].as<float>()) : 0.0f;
800+
// Load sensor native range settings
801+
cfg.tanks[i].sensorRangeMin = t["sensorRangeMin"].is<float>() ? t["sensorRangeMin"].as<float>() : 0.0f;
802+
cfg.tanks[i].sensorRangeMax = t["sensorRangeMax"].is<float>() ? t["sensorRangeMax"].as<float>() : 5.0f;
803+
const char *rangeUnitStr = t["sensorRangeUnit"].as<const char *>();
804+
strlcpy(cfg.tanks[i].sensorRangeUnit, rangeUnitStr ? rangeUnitStr : "PSI", sizeof(cfg.tanks[i].sensorRangeUnit));
771805
}
772806

773807
return true;
@@ -838,6 +872,17 @@ static bool saveConfigToFlash(const ClientConfig &cfg) {
838872
}
839873
// Save digital switch mode (NO/NC)
840874
t["digitalSwitchMode"] = cfg.tanks[i].digitalSwitchMode;
875+
// Save 4-20mA current loop sensor type
876+
switch (cfg.tanks[i].currentLoopType) {
877+
case CURRENT_LOOP_ULTRASONIC: t["currentLoopType"] = "ultrasonic"; break;
878+
default: t["currentLoopType"] = "pressure"; break;
879+
}
880+
// Save sensor mount height (for calibration)
881+
t["sensorMountHeight"] = cfg.tanks[i].sensorMountHeight;
882+
// Save sensor native range settings
883+
t["sensorRangeMin"] = cfg.tanks[i].sensorRangeMin;
884+
t["sensorRangeMax"] = cfg.tanks[i].sensorRangeMax;
885+
t["sensorRangeUnit"] = cfg.tanks[i].sensorRangeUnit;
841886
}
842887

843888
#if defined(ARDUINO_OPTA) || defined(ARDUINO_ARCH_MBED)
@@ -895,13 +940,20 @@ static void printHardwareRequirements(const ClientConfig &cfg) {
895940
bool needsCurrentLoop = false;
896941
bool needsRelayOutput = false;
897942
bool needsRpmSensor = false;
943+
bool hasPressureSensor = false;
944+
bool hasUltrasonicSensor = false;
898945

899946
for (uint8_t i = 0; i < cfg.tankCount; ++i) {
900947
if (cfg.tanks[i].sensorType == SENSOR_ANALOG) {
901948
needsAnalogExpansion = true;
902949
}
903950
if (cfg.tanks[i].sensorType == SENSOR_CURRENT_LOOP) {
904951
needsCurrentLoop = true;
952+
if (cfg.tanks[i].currentLoopType == CURRENT_LOOP_PRESSURE) {
953+
hasPressureSensor = true;
954+
} else if (cfg.tanks[i].currentLoopType == CURRENT_LOOP_ULTRASONIC) {
955+
hasUltrasonicSensor = true;
956+
}
905957
}
906958
if (cfg.tanks[i].sensorType == SENSOR_HALL_EFFECT_RPM) {
907959
needsRpmSensor = true;
@@ -919,6 +971,12 @@ static void printHardwareRequirements(const ClientConfig &cfg) {
919971
}
920972
if (needsCurrentLoop) {
921973
Serial.println(F("Current loop interface required (4-20mA module)"));
974+
if (hasPressureSensor) {
975+
Serial.println(F(" - Pressure sensor (bottom-mounted, e.g., Dwyer 626-06-CB-P1-E5-S1)"));
976+
}
977+
if (hasUltrasonicSensor) {
978+
Serial.println(F(" - Ultrasonic sensor (top-mounted, e.g., Siemens Sitrans LU240)"));
979+
}
922980
}
923981
if (needsRpmSensor) {
924982
Serial.println(F("Hall effect RPM sensor connected to digital input"));
@@ -1280,6 +1338,30 @@ static void applyConfigUpdate(const JsonDocument &doc) {
12801338
strlcpy(gConfig.tanks[i].digitalSwitchMode, "NO", sizeof(gConfig.tanks[i].digitalSwitchMode));
12811339
}
12821340
}
1341+
// Handle 4-20mA current loop sensor type (pressure or ultrasonic)
1342+
if (t.containsKey("currentLoopType")) {
1343+
const char *currentLoopTypeStr = t["currentLoopType"].as<const char *>();
1344+
if (currentLoopTypeStr && strcmp(currentLoopTypeStr, "ultrasonic") == 0) {
1345+
gConfig.tanks[i].currentLoopType = CURRENT_LOOP_ULTRASONIC;
1346+
} else {
1347+
gConfig.tanks[i].currentLoopType = CURRENT_LOOP_PRESSURE;
1348+
}
1349+
}
1350+
// Handle sensor mount height (for calibration) - validate non-negative
1351+
if (t.containsKey("sensorMountHeight")) {
1352+
gConfig.tanks[i].sensorMountHeight = fmaxf(0.0f, t["sensorMountHeight"].as<float>());
1353+
}
1354+
// Handle sensor native range settings
1355+
if (t.containsKey("sensorRangeMin")) {
1356+
gConfig.tanks[i].sensorRangeMin = t["sensorRangeMin"].as<float>();
1357+
}
1358+
if (t.containsKey("sensorRangeMax")) {
1359+
gConfig.tanks[i].sensorRangeMax = t["sensorRangeMax"].as<float>();
1360+
}
1361+
if (t.containsKey("sensorRangeUnit")) {
1362+
const char *unitStr = t["sensorRangeUnit"].as<const char *>();
1363+
strlcpy(gConfig.tanks[i].sensorRangeUnit, unitStr ? unitStr : "PSI", sizeof(gConfig.tanks[i].sensorRangeUnit));
1364+
}
12831365
}
12841366
}
12851367

@@ -1480,7 +1562,36 @@ static float readTankSensor(uint8_t idx) {
14801562
if (milliamps < 0.0f) {
14811563
return gTankState[idx].currentInches; // keep previous on failure
14821564
}
1483-
return linearMap(milliamps, 4.0f, 20.0f, 0.0f, cfg.maxValue);
1565+
1566+
// Handle different 4-20mA sensor types
1567+
float levelInches;
1568+
if (cfg.currentLoopType == CURRENT_LOOP_ULTRASONIC) {
1569+
// Ultrasonic sensor mounted on TOP of tank (e.g., Siemens Sitrans LU240)
1570+
// 4mA = full tank (sensor close to liquid surface)
1571+
// 20mA = empty tank (sensor far from liquid surface)
1572+
// sensorMountHeight = distance from sensor to tank bottom when empty
1573+
// maxValue = tank height (max liquid level)
1574+
// The sensor measures distance from sensor to liquid surface
1575+
// Distance at 4mA = 0 (full), Distance at 20mA = sensorMountHeight (empty)
1576+
float distanceFromSensor = linearMap(milliamps, 4.0f, 20.0f, 0.0f, cfg.sensorMountHeight);
1577+
levelInches = cfg.sensorMountHeight - distanceFromSensor;
1578+
// Clamp to valid range (0 to maxValue)
1579+
if (levelInches < 0.0f) levelInches = 0.0f;
1580+
if (levelInches > cfg.maxValue) levelInches = cfg.maxValue;
1581+
} else {
1582+
// Pressure sensor mounted near BOTTOM of tank (e.g., Dwyer 626-06-CB-P1-E5-S1)
1583+
// 4mA = empty tank (0 PSI / no liquid above sensor)
1584+
// 20mA = full tank (max PSI / max liquid height above sensor)
1585+
// sensorMountHeight = height of sensor above tank bottom (usually 0-2 inches)
1586+
// maxValue = maximum liquid height the sensor measures (corresponds to max PSI)
1587+
float rawLevel = linearMap(milliamps, 4.0f, 20.0f, 0.0f, cfg.maxValue);
1588+
// Add mount height offset (sensor is mounted above tank bottom)
1589+
levelInches = rawLevel + cfg.sensorMountHeight;
1590+
// Clamp to valid range (sensorMountHeight to maxValue + sensorMountHeight)
1591+
if (levelInches < cfg.sensorMountHeight) levelInches = cfg.sensorMountHeight;
1592+
if (levelInches > cfg.maxValue + cfg.sensorMountHeight) levelInches = cfg.maxValue + cfg.sensorMountHeight;
1593+
}
1594+
return levelInches;
14841595
}
14851596
case SENSOR_HALL_EFFECT_RPM: {
14861597
// Hall effect RPM sensor - sample pulses for a few seconds each measurement period

0 commit comments

Comments
 (0)