Skip to content

Commit 1d6465b

Browse files
Copilotdorkmo
andcommitted
Add 4mA-20mA sensor type options (pressure vs ultrasonic)
Co-authored-by: dorkmo <[email protected]>
1 parent 882e5f9 commit 1d6465b

File tree

2 files changed

+178
-2
lines changed

2 files changed

+178
-2
lines changed

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

Lines changed: 83 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,10 @@ 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)
254266
};
255267

256268
struct ClientConfig {
@@ -633,6 +645,8 @@ static void createDefaultConfig(ClientConfig &cfg) {
633645
cfg.tanks[0].relayMode = RELAY_MODE_MOMENTARY; // Default: momentary 30 min activation
634646
cfg.tanks[0].digitalTrigger[0] = '\0'; // Not a digital sensor by default
635647
strlcpy(cfg.tanks[0].digitalSwitchMode, "NO", sizeof(cfg.tanks[0].digitalSwitchMode)); // Default: normally-open
648+
cfg.tanks[0].currentLoopType = CURRENT_LOOP_PRESSURE; // Default: pressure sensor (most common)
649+
cfg.tanks[0].sensorMountHeight = 0.0f; // Default: sensor at tank bottom
636650
}
637651

638652
static bool loadConfigFromFlash(ClientConfig &cfg) {
@@ -768,6 +782,15 @@ static bool loadConfigFromFlash(ClientConfig &cfg) {
768782
} else {
769783
strlcpy(cfg.tanks[i].digitalSwitchMode, "NO", sizeof(cfg.tanks[i].digitalSwitchMode)); // Default: normally-open
770784
}
785+
// Load 4-20mA current loop sensor type (pressure or ultrasonic)
786+
const char *currentLoopTypeStr = t["currentLoopType"].as<const char *>();
787+
if (currentLoopTypeStr && strcmp(currentLoopTypeStr, "ultrasonic") == 0) {
788+
cfg.tanks[i].currentLoopType = CURRENT_LOOP_ULTRASONIC;
789+
} else {
790+
cfg.tanks[i].currentLoopType = CURRENT_LOOP_PRESSURE; // Default: pressure sensor
791+
}
792+
// Load sensor mount height (for calibration)
793+
cfg.tanks[i].sensorMountHeight = t["sensorMountHeight"].is<float>() ? t["sensorMountHeight"].as<float>() : 0.0f;
771794
}
772795

773796
return true;
@@ -838,6 +861,13 @@ static bool saveConfigToFlash(const ClientConfig &cfg) {
838861
}
839862
// Save digital switch mode (NO/NC)
840863
t["digitalSwitchMode"] = cfg.tanks[i].digitalSwitchMode;
864+
// Save 4-20mA current loop sensor type
865+
switch (cfg.tanks[i].currentLoopType) {
866+
case CURRENT_LOOP_ULTRASONIC: t["currentLoopType"] = "ultrasonic"; break;
867+
default: t["currentLoopType"] = "pressure"; break;
868+
}
869+
// Save sensor mount height (for calibration)
870+
t["sensorMountHeight"] = cfg.tanks[i].sensorMountHeight;
841871
}
842872

843873
#if defined(ARDUINO_OPTA) || defined(ARDUINO_ARCH_MBED)
@@ -895,13 +925,20 @@ static void printHardwareRequirements(const ClientConfig &cfg) {
895925
bool needsCurrentLoop = false;
896926
bool needsRelayOutput = false;
897927
bool needsRpmSensor = false;
928+
bool hasPressureSensor = false;
929+
bool hasUltrasonicSensor = false;
898930

899931
for (uint8_t i = 0; i < cfg.tankCount; ++i) {
900932
if (cfg.tanks[i].sensorType == SENSOR_ANALOG) {
901933
needsAnalogExpansion = true;
902934
}
903935
if (cfg.tanks[i].sensorType == SENSOR_CURRENT_LOOP) {
904936
needsCurrentLoop = true;
937+
if (cfg.tanks[i].currentLoopType == CURRENT_LOOP_PRESSURE) {
938+
hasPressureSensor = true;
939+
} else if (cfg.tanks[i].currentLoopType == CURRENT_LOOP_ULTRASONIC) {
940+
hasUltrasonicSensor = true;
941+
}
905942
}
906943
if (cfg.tanks[i].sensorType == SENSOR_HALL_EFFECT_RPM) {
907944
needsRpmSensor = true;
@@ -919,6 +956,12 @@ static void printHardwareRequirements(const ClientConfig &cfg) {
919956
}
920957
if (needsCurrentLoop) {
921958
Serial.println(F("Current loop interface required (4-20mA module)"));
959+
if (hasPressureSensor) {
960+
Serial.println(F(" - Pressure sensor (bottom-mounted, e.g., Dwyer 626-06-CB-P1-E5-S1)"));
961+
}
962+
if (hasUltrasonicSensor) {
963+
Serial.println(F(" - Ultrasonic sensor (top-mounted, e.g., Siemens Sitrans LU240)"));
964+
}
922965
}
923966
if (needsRpmSensor) {
924967
Serial.println(F("Hall effect RPM sensor connected to digital input"));
@@ -1280,6 +1323,19 @@ static void applyConfigUpdate(const JsonDocument &doc) {
12801323
strlcpy(gConfig.tanks[i].digitalSwitchMode, "NO", sizeof(gConfig.tanks[i].digitalSwitchMode));
12811324
}
12821325
}
1326+
// Handle 4-20mA current loop sensor type (pressure or ultrasonic)
1327+
if (t.containsKey("currentLoopType")) {
1328+
const char *currentLoopTypeStr = t["currentLoopType"].as<const char *>();
1329+
if (currentLoopTypeStr && strcmp(currentLoopTypeStr, "ultrasonic") == 0) {
1330+
gConfig.tanks[i].currentLoopType = CURRENT_LOOP_ULTRASONIC;
1331+
} else {
1332+
gConfig.tanks[i].currentLoopType = CURRENT_LOOP_PRESSURE;
1333+
}
1334+
}
1335+
// Handle sensor mount height (for calibration)
1336+
if (t.containsKey("sensorMountHeight")) {
1337+
gConfig.tanks[i].sensorMountHeight = t["sensorMountHeight"].as<float>();
1338+
}
12831339
}
12841340
}
12851341

@@ -1480,7 +1536,33 @@ static float readTankSensor(uint8_t idx) {
14801536
if (milliamps < 0.0f) {
14811537
return gTankState[idx].currentInches; // keep previous on failure
14821538
}
1483-
return linearMap(milliamps, 4.0f, 20.0f, 0.0f, cfg.maxValue);
1539+
1540+
// Handle different 4-20mA sensor types
1541+
float levelInches;
1542+
if (cfg.currentLoopType == CURRENT_LOOP_ULTRASONIC) {
1543+
// Ultrasonic sensor mounted on TOP of tank (e.g., Siemens Sitrans LU240)
1544+
// 4mA = full tank (sensor close to liquid surface)
1545+
// 20mA = empty tank (sensor far from liquid surface)
1546+
// sensorMountHeight = distance from sensor to tank bottom when empty
1547+
// maxValue = tank height (max liquid level)
1548+
// The sensor measures distance from sensor to liquid surface
1549+
// Distance at 4mA = 0 (full), Distance at 20mA = sensorMountHeight (empty)
1550+
float distanceFromSensor = linearMap(milliamps, 4.0f, 20.0f, 0.0f, cfg.sensorMountHeight);
1551+
levelInches = cfg.sensorMountHeight - distanceFromSensor;
1552+
// Clamp to valid range
1553+
if (levelInches < 0.0f) levelInches = 0.0f;
1554+
if (levelInches > cfg.maxValue) levelInches = cfg.maxValue;
1555+
} else {
1556+
// Pressure sensor mounted near BOTTOM of tank (e.g., Dwyer 626-06-CB-P1-E5-S1)
1557+
// 4mA = empty tank (0 PSI / no liquid above sensor)
1558+
// 20mA = full tank (max PSI / max liquid height above sensor)
1559+
// sensorMountHeight = height of sensor above tank bottom (usually 0-2 inches)
1560+
// maxValue = maximum liquid height the sensor measures (corresponds to max PSI)
1561+
float rawLevel = linearMap(milliamps, 4.0f, 20.0f, 0.0f, cfg.maxValue);
1562+
// Add mount height offset (sensor is mounted above tank bottom)
1563+
levelInches = rawLevel + cfg.sensorMountHeight;
1564+
}
1565+
return levelInches;
14841566
}
14851567
case SENSOR_HALL_EFFECT_RPM: {
14861568
// Hall effect RPM sensor - sample pulses for a few seconds each measurement period

TankAlarm-112025-Server-BluesOpta/TankAlarm-112025-Server-BluesOpta.ino

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,12 @@ static const char CONFIG_GENERATOR_HTML[] PROGMEM = R"HTML(
675675
{ value: 3, label: 'Hall Effect RPM' }
676676
];
677677
678+
// 4-20mA current loop sensor subtypes
679+
const currentLoopTypes = [
680+
{ value: 'pressure', label: 'Pressure Sensor (Bottom-Mounted)', tooltip: 'Pressure sensor mounted near tank bottom (e.g., Dwyer 626-06-CB-P1-E5-S1). 4mA = empty, 20mA = full.' },
681+
{ value: 'ultrasonic', label: 'Ultrasonic Sensor (Top-Mounted)', tooltip: 'Ultrasonic level sensor mounted on tank top (e.g., Siemens Sitrans LU240). 4mA = full, 20mA = empty.' }
682+
];
683+
678684
const monitorTypes = [
679685
{ value: 'tank', label: 'Tank Level' },
680686
{ value: 'gas', label: 'Gas Pressure' },
@@ -737,6 +743,13 @@ static const char CONFIG_GENERATOR_HTML[] PROGMEM = R"HTML(
737743
</select>
738744
</label>
739745
<label class="field pulses-per-rev-field" style="display: none;"><span>Pulses/Rev</span><input type="number" class="pulses-per-rev" value="1" min="1" max="255"></label>
746+
<label class="field current-loop-type-field" style="display: none;"><span>4-20mA Sensor Type<span class="tooltip-icon" tabindex="0" data-tooltip="Select the type of 4-20mA sensor: Pressure sensors are mounted near the tank bottom and measure liquid pressure. Ultrasonic sensors are mounted on top of the tank and measure distance to the liquid surface.">?</span></span>
747+
<select class="current-loop-type" onchange="updateCurrentLoopFields(${id})">
748+
<option value="pressure">Pressure Sensor (Bottom-Mounted)</option>
749+
<option value="ultrasonic">Ultrasonic Sensor (Top-Mounted)</option>
750+
</select>
751+
</label>
752+
<label class="field sensor-mount-height-field" style="display: none;"><span><span class="mount-height-label">Sensor Mount Height (in)</span><span class="tooltip-icon mount-height-tooltip" tabindex="0" data-tooltip="For pressure sensors: height of sensor above tank bottom (usually 0-2 inches). For ultrasonic sensors: distance from sensor to tank bottom when empty.">?</span></span><input type="number" class="sensor-mount-height" value="0" step="0.1" min="0"></label>
740753
<label class="field height-field"><span><span class="height-label">Height (in)</span><span class="tooltip-icon height-tooltip" tabindex="0" data-tooltip="Maximum height or capacity of the tank in inches. Used to calculate fill percentage and set alarm thresholds relative to tank size.">?</span></span><input type="number" class="tank-height" value="120"></label>
741754
</div>
742755
@@ -746,6 +759,22 @@ static const char CONFIG_GENERATOR_HTML[] PROGMEM = R"HTML(
746759
<strong>Wiring Note:</strong> For both NO and NC switches, connect the switch between the input pin and GND. The software uses an internal pull-up resistor and interprets the signal based on your selected switch mode.
747760
</div>
748761
762+
<!-- 4-20mA sensor info box (shown only for current loop sensors) -->
763+
<div class="current-loop-sensor-info" style="display: none; background: var(--chip); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px; margin-top: 8px; font-size: 0.9rem; color: var(--muted);">
764+
<div class="pressure-sensor-info">
765+
<strong>Pressure Sensor (Bottom-Mounted):</strong> Installed near the bottom of the tank, this sensor measures the pressure of the liquid column above it. Examples: Dwyer 626-06-CB-P1-E5-S1 (0-5 PSI).<br>
766+
• 4mA = Empty tank (0 PSI)<br>
767+
• 20mA = Full tank (max PSI)<br>
768+
• Mount Height: Distance from sensor to tank bottom (usually 0-2 inches)
769+
</div>
770+
<div class="ultrasonic-sensor-info" style="display: none;">
771+
<strong>Ultrasonic Sensor (Top-Mounted):</strong> Mounted on top of the tank, this sensor measures the distance from the sensor to the liquid surface. Examples: Siemens Sitrans LU240.<br>
772+
• 4mA = Full tank (liquid close to sensor)<br>
773+
• 20mA = Empty tank (liquid far from sensor)<br>
774+
• Sensor Mount Height: Distance from sensor to tank bottom when empty
775+
</div>
776+
</div>
777+
749778
<button type="button" class="add-section-btn add-alarm-btn" onclick="toggleAlarmSection(${id})">+ Add Alarm</button>
750779
<div class="collapsible-section alarm-section">
751780
<h4 style="margin: 16px 0 8px; font-size: 0.95rem; border-top: 1px solid var(--card-border); padding-top: 12px;"><span class="alarm-section-title">Alarm Thresholds</span> <button type="button" class="remove-btn" onclick="removeAlarmSection(${id})" style="float: right;">Remove Alarm</button></h4>
@@ -998,49 +1027,106 @@ static const char CONFIG_GENERATOR_HTML[] PROGMEM = R"HTML(
9981027
const heightLabel = card.querySelector('.height-label');
9991028
const heightTooltip = card.querySelector('.height-tooltip');
10001029
const digitalInfoBox = card.querySelector('.digital-sensor-info');
1030+
const currentLoopInfoBox = card.querySelector('.current-loop-sensor-info');
10011031
const alarmThresholdsGrid = card.querySelector('.alarm-thresholds-grid');
10021032
const digitalAlarmGrid = card.querySelector('.digital-alarm-grid');
10031033
const alarmSectionTitle = card.querySelector('.alarm-section-title');
10041034
const pulsesPerRevField = card.querySelector('.pulses-per-rev-field');
10051035
const switchModeField = card.querySelector('.switch-mode-field');
1036+
const currentLoopTypeField = card.querySelector('.current-loop-type-field');
1037+
const sensorMountHeightField = card.querySelector('.sensor-mount-height-field');
10061038
10071039
// Digital Input (Float Switch) - type === 0
10081040
if (type === 0) {
10091041
// Hide height field (not applicable for float switches)
10101042
heightField.style.display = 'none';
10111043
// Show digital sensor info box
10121044
digitalInfoBox.style.display = 'block';
1045+
currentLoopInfoBox.style.display = 'none';
10131046
// Show switch mode selector for digital sensors
10141047
switchModeField.style.display = 'flex';
1048+
// Hide current loop fields
1049+
currentLoopTypeField.style.display = 'none';
1050+
sensorMountHeightField.style.display = 'none';
10151051
// Update alarm section for digital sensors
10161052
alarmThresholdsGrid.style.display = 'none';
10171053
digitalAlarmGrid.style.display = 'grid';
10181054
alarmSectionTitle.textContent = 'Float Switch Alarm';
10191055
pulsesPerRevField.style.display = 'none';
1056+
} else if (type === 2) { // Current Loop (4-20mA)
1057+
heightField.style.display = 'flex';
1058+
heightLabel.textContent = 'Tank Height (in)';
1059+
heightTooltip.setAttribute('data-tooltip', 'Maximum height of liquid in the tank in inches. For ultrasonic sensors, this is the distance the sensor measures at full tank.');
1060+
digitalInfoBox.style.display = 'none';
1061+
currentLoopInfoBox.style.display = 'block';
1062+
switchModeField.style.display = 'none';
1063+
// Show current loop type and mount height fields
1064+
currentLoopTypeField.style.display = 'flex';
1065+
sensorMountHeightField.style.display = 'flex';
1066+
alarmThresholdsGrid.style.display = 'grid';
1067+
digitalAlarmGrid.style.display = 'none';
1068+
alarmSectionTitle.textContent = 'Alarm Thresholds';
1069+
pulsesPerRevField.style.display = 'none';
1070+
// Update current loop specific info
1071+
updateCurrentLoopFields(id);
10201072
} else if (type === 3) { // Hall Effect RPM
10211073
heightField.style.display = 'flex';
10221074
heightLabel.textContent = 'Max RPM';
10231075
heightTooltip.setAttribute('data-tooltip', 'Maximum expected RPM value. Used for alarm threshold reference.');
10241076
digitalInfoBox.style.display = 'none';
1077+
currentLoopInfoBox.style.display = 'none';
10251078
switchModeField.style.display = 'none';
1079+
currentLoopTypeField.style.display = 'none';
1080+
sensorMountHeightField.style.display = 'none';
10261081
alarmThresholdsGrid.style.display = 'grid';
10271082
digitalAlarmGrid.style.display = 'none';
10281083
alarmSectionTitle.textContent = 'Alarm Thresholds';
10291084
pulsesPerRevField.style.display = 'flex';
10301085
} else {
1031-
// Analog or Current Loop sensors
1086+
// Analog sensors
10321087
heightField.style.display = 'flex';
10331088
heightLabel.textContent = 'Height (in)';
10341089
heightTooltip.setAttribute('data-tooltip', 'Maximum height or capacity of the tank in inches. Used to calculate fill percentage and set alarm thresholds relative to tank size.');
10351090
digitalInfoBox.style.display = 'none';
1091+
currentLoopInfoBox.style.display = 'none';
10361092
switchModeField.style.display = 'none';
1093+
currentLoopTypeField.style.display = 'none';
1094+
sensorMountHeightField.style.display = 'none';
10371095
alarmThresholdsGrid.style.display = 'grid';
10381096
digitalAlarmGrid.style.display = 'none';
10391097
alarmSectionTitle.textContent = 'Alarm Thresholds';
10401098
pulsesPerRevField.style.display = 'none';
10411099
}
10421100
};
10431101
1102+
window.updateCurrentLoopFields = function(id) {
1103+
const card = document.getElementById(`sensor-${id}`);
1104+
const currentLoopType = card.querySelector('.current-loop-type').value;
1105+
const currentLoopInfoBox = card.querySelector('.current-loop-sensor-info');
1106+
const pressureInfo = currentLoopInfoBox.querySelector('.pressure-sensor-info');
1107+
const ultrasonicInfo = currentLoopInfoBox.querySelector('.ultrasonic-sensor-info');
1108+
const mountHeightLabel = card.querySelector('.mount-height-label');
1109+
const mountHeightTooltip = card.querySelector('.mount-height-tooltip');
1110+
const heightLabel = card.querySelector('.height-label');
1111+
const heightTooltip = card.querySelector('.height-tooltip');
1112+
1113+
if (currentLoopType === 'ultrasonic') {
1114+
pressureInfo.style.display = 'none';
1115+
ultrasonicInfo.style.display = 'block';
1116+
mountHeightLabel.textContent = 'Sensor Mount Height (in)';
1117+
mountHeightTooltip.setAttribute('data-tooltip', 'Distance from the ultrasonic sensor to the tank bottom when empty. This is used to calculate the actual liquid level.');
1118+
heightLabel.textContent = 'Tank Height (in)';
1119+
heightTooltip.setAttribute('data-tooltip', 'Maximum liquid height in the tank. When the tank is full, the liquid level equals this value.');
1120+
} else {
1121+
pressureInfo.style.display = 'block';
1122+
ultrasonicInfo.style.display = 'none';
1123+
mountHeightLabel.textContent = 'Sensor Mount Height (in)';
1124+
mountHeightTooltip.setAttribute('data-tooltip', 'Height of the pressure sensor above the tank bottom (usually 0-2 inches). This offset is added to the measured level.');
1125+
heightLabel.textContent = 'Max Measured Height (in)';
1126+
heightTooltip.setAttribute('data-tooltip', 'Maximum liquid height the sensor can measure (corresponds to 20mA / max PSI). Does not include the sensor mount height offset.');
1127+
}
1128+
};
1129+
10441130
document.getElementById('addSensorBtn').addEventListener('click', addSensor);
10451131
10461132
function sensorKeyFromValue(value) {
@@ -1128,6 +1214,14 @@ static const char CONFIG_GENERATOR_HTML[] PROGMEM = R"HTML(
11281214
tank.digitalSwitchMode = switchMode; // 'NO' or 'NC'
11291215
}
11301216
1217+
// Add current loop sensor type and mount height for 4-20mA sensors
1218+
if (sensor === 'current') {
1219+
const currentLoopType = card.querySelector('.current-loop-type').value;
1220+
const sensorMountHeight = parseFloat(card.querySelector('.sensor-mount-height').value) || 0;
1221+
tank.currentLoopType = currentLoopType; // 'pressure' or 'ultrasonic'
1222+
tank.sensorMountHeight = sensorMountHeight;
1223+
}
1224+
11311225
// Handle alarms differently based on sensor type
11321226
if (alarmSectionVisible) {
11331227
if (sensor === 'digital') {

0 commit comments

Comments
 (0)