// SPDX-FileCopyrightText: 2025 Amal Mazrah // SPDX-FileCopyrightText: 2025 Jonas Smedegaard // SPDX-FileCopyrightText: 2025 Mennatullah Hatim Kassim // SPDX-FileCopyrightText: 2025 Noor Ahmad // SPDX-FileCopyrightText: 2025 Tanishka Suwalka // SPDX-License-Identifier: GPL-3.0-or-later /// Mussel vote - an Arduino sketch to monitor mussel biosensors /// /// @version 0.0.3 /// @see /// @see // arduino-esp32 Logging system // activate in Arduino IDE: Tools -> Core Debug Level // special: set Core Debug Level to Error for plot-friendly output #define CONFIG_ARDUHAL_ESP_LOG 1 #define LOG_LOCAL_LEVEL CORE_DEBUG_LEVEL #include #undef ARDUHAL_LOG_FORMAT #define ARDUHAL_LOG_FORMAT(letter, format) \ ARDUHAL_LOG_COLOR_##letter "[" #letter "] %s(): " format \ ARDUHAL_LOG_RESET_COLOR "\r\n", __FUNCTION__ // arduino-esp32 Bluetooth Low Energy (BLE) networking stack #include #include #include #include #include #define SCAN_INTERVAL 100 #define SCAN_WINDOW 99 #define SCAN_TIME_SEC 1 // stack sizes for voters and ballots-per-voter #define VOTER_MAX 10 #define BALLOT_MAX 5 // Validity timing thresholds const unsigned long VOTE_TIME_TOLERANCE = 1 * 60 * 1000; // 1 minute // track vote outcome #define LED_PIN LED_BUILTIN #define LED1_PIN 2 // Blue #define LED2_PIN 33 // Green bool waterIsDrinkable = true; // true means LED off, false means LED on // Classify gape state enum MusselGapState { Closed, Open }; // Data structures struct Vote { unsigned long timestamp; int measure; }; struct Voter { String id; // Mussel ID Vote votes[BALLOT_MAX]; // Last 5 sensor readings int voteCount = 0; // Number of readings stored }; // Global array of mussel voters Voter voters[VOTER_MAX]; int voterCount = 0; // pointer to control Bluetooth networking BLEScan *pBLEScan; /// Find index of mussel ID in the voters array int findVoterIndex(const String& id) { for (int i = 0; i < voterCount; i++) { if (voters[i].id == id) return i; } return -1; // Not found } /// Add or update data for a mussel voting ballot void collectBallotData( const String& id, unsigned long timestamp, int gape_measure ) { int index = findVoterIndex(id); // If mussel not found, add new if (index == -1) { if (voterCount >= VOTER_MAX) { log_i("Ignored: Max mussel limit reached (%s)", id.c_str()); return; } voters[voterCount].id = id; voters[voterCount].voteCount = 0; index = voterCount++; } Voter &voter = voters[index]; // Maintain a fixed number of stored ballots (FIFO logic) if (voter.voteCount >= BALLOT_MAX) { for (int i = 1; i < BALLOT_MAX; i++) { voter.votes[i - 1] = voter.votes[i]; } voter.voteCount = BALLOT_MAX - 1; } // Store the new draft ballot at the end voter.votes[voter.voteCount++] = {timestamp, gape_measure}; log_i("Vote stored: Time: %lu, Mussel: %s, Gape: %d", timestamp, id.c_str(), gape_measure); } /// Clean outdated voting ballots and mussels with no valid ballots void cleanOldBallotData() { unsigned long now = millis(); for (int i = 0; i < voterCount; ) { Voter &voter = voters[i]; int newCount = 0; // Shift valid ballots to the front for (int j = 0; j < voter.voteCount; j++) { unsigned long age = now - voter.votes[j].timestamp; if (age < VOTE_TIME_TOLERANCE) { voter.votes[newCount++] = voter.votes[j]; } else { log_i("Dropped old ballot for Mussel %s (age: %lums)", voter.id.c_str(), age); } } voter.voteCount = newCount; // If all ballots are dropped, remove the mussel if (voter.voteCount == 0) { log_i("Removing Mussel %s - No valid ballots left", voter.id.c_str()); for (int k = i; k < voterCount - 1; k++) { voters[k] = voters[k + 1]; } voterCount--; continue; // Do not increment i since the list shifted } i++; // Only increment if we didn't remove current voter } } /// Classify mussel state based on topmost vote void alignVotes() { for (int i = 0; i < voterCount; i++) { Voter &voter = voters[i]; // Skip mussels with no data if (voter.voteCount == 0) { log_i("Mussel ID: %s - No data", voter.id.c_str()); continue; } // Use latest vote to determine state Vote latest = voter.votes[voter.voteCount - 1]; String state = (latest.measure >= 0 && latest.measure < 40) ? "Closed" : (latest.measure >= 40 && latest.measure <= 90) ? "Open" : "Invalid reading"; log_i("Mussel ID: %s | Latest Gape: %d | State: %s", voter.id.c_str(), latest.measure, state.c_str()); } } /// Decide whether a voting ballot is valid based on age const char* qualifyBallot( unsigned long voteTimestamp, unsigned long now ) { unsigned long age = now - voteTimestamp; if (age <= VOTE_TIME_TOLERANCE) { log_i("VALID: Ballot is within 1 minute (age: %lums)", age); return "valid"; } else { log_i("INVALID: Ballot is older than 1 minute (age: %lums)", age); return "invalid"; } } /// Resolve the outcome of the voting procedure void concludeVote() { int openVotes = 0; int totalValidVotes = 0; // First, align votes and qualify them for (int i = 0; i < voterCount; i++) { Voter &voter = voters[i]; // Skip mussels with no data if (voter.voteCount == 0) continue; // Use latest vote to determine state Vote latest = voter.votes[voter.voteCount - 1]; unsigned long now = millis(); // Check if the vote is valid using the qualify function const char* validity = qualifyBallot(latest.timestamp, now); if (strcmp(validity, "valid") == 0) { totalValidVotes++; // Count valid votes // Align and classify mussel state String state = (latest.measure >= 0 && latest.measure < 40) ? "Closed" : (latest.measure >= 40 && latest.measure <= 90) ? "Open" : "Invalid reading"; // Count valid "Open" votes if (state == "Open") { openVotes++; } } } // Determine the threshold (half of total valid votes, rounded down) int threshold = totalValidVotes / 2; waterIsDrinkable = (openVotes >= threshold); // Same or more "Open" votes than the threshold means water is ok if (openVotes >= threshold) { log_i("YES, water is drinkable (open: %d, valid: %d)", openVotes, totalValidVotes); } else { log_i("NO, water is not drinkable (open: %d, valid: %d)", openVotes, totalValidVotes); } } // Bluetooth beacon discovery callbacks class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks { // decode name and temperature from Eddystone TLM advertisement void onResult(BLEAdvertisedDevice advertisedDevice) { if (advertisedDevice.haveName() && advertisedDevice.getFrameType() == BLE_EDDYSTONE_TLM_FRAME ) { BLEEddystoneTLM EddystoneTLM(&advertisedDevice); // misuse error-only log level for plot-friendly output #if ARDUHAL_LOG_LEVEL == ARDUHAL_LOG_LEVEL_ERROR String id_mangled = advertisedDevice.getName(); id_mangled.replace(' ', '_'); id_mangled.replace(':', '='); Serial.println(id_mangled + ":" + EddystoneTLM.getTemp()); #endif unsigned long now = millis(); String musselID = advertisedDevice.getName(); int gape = EddystoneTLM.getTemp(); collectBallotData(musselID, now, gape); } } }; void setup() { // enable logging to serial Serial.begin(115200); esp_log_level_set("*", ESP_LOG_DEBUG); // start with LED off (HIGH = off on active-low boards) pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, HIGH); pinMode(LED1_PIN, OUTPUT); pinMode(LED2_PIN, OUTPUT); digitalWrite(LED1_PIN, LOW); digitalWrite(LED2_PIN, LOW); // setup Bluetooth BLEDevice::init(""); pBLEScan = BLEDevice::getScan(); pBLEScan->setAdvertisedDeviceCallbacks( new MyAdvertisedDeviceCallbacks()); pBLEScan->setActiveScan(true); pBLEScan->setInterval(SCAN_INTERVAL); pBLEScan->setWindow(SCAN_WINDOW); } void loop() { pBLEScan->start(SCAN_TIME_SEC, false); pBLEScan->clearResults(); alignVotes(); concludeVote(); cleanOldBallotData(); // Tidy the voter stack digitalWrite(LED_PIN, waterIsDrinkable ? LOW : HIGH); // LED Logic: GREEN if drinkable, RED if not if (waterIsDrinkable) { digitalWrite(LED2_PIN, HIGH); // GREEN ON digitalWrite(LED1_PIN, LOW); // RED OFF Serial.println("Water is DRINKABLE - GREEN LED ON"); } else { digitalWrite(LED2_PIN, LOW); // GREEN OFF digitalWrite(LED1_PIN, HIGH); // RED ON Serial.println("Water is NOT DRINKABLE - RED LED ON"); } delay(500); }