// 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 // 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 vote for a mussel ID void storeVoteForMussel( 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 votes (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 vote 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 votes and mussels with no valid votes void cleanOldVotes() { unsigned long now = millis(); for (int i = 0; i < voterCount; ) { Voter &voter = voters[i]; int newCount = 0; // Shift valid votes 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 vote for Mussel %s | Age: %lu ms", voter.id.c_str(), age); } } voter.voteCount = newCount; // If all votes are dropped, remove the mussel if (voter.voteCount == 0) { log_i("Removing Mussel %s - No valid votes 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 vote is valid based on age const char* qualifyMusselVote( unsigned long voteTimestamp, unsigned long now ) { unsigned long age = now - voteTimestamp; // Log vote age and gape log_i("Qualifying vote | Time since vote: %lu ms", age); if (age <= VOTE_TIME_TOLERANCE) { log_i("→ VALID: Vote is within 1 minute"); return "valid"; } else { log_i("→ INVALID: Vote is older than 1 minute"); return "invalid"; } } /// Output the final vote decision for mussels void concludeMusselVote() { 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 = qualifyMusselVote(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; // 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(); // 1. Store vote storeVoteForMussel(musselID, now, gape); // 2. Align alignVotes(); // 3. Qualify const char* validity = qualifyMusselVote( now, millis()); // 4. Conclude concludeMusselVote(); } } }; void setup() { // enable logging to serial Serial.begin(115200); esp_log_level_set("*", ESP_LOG_DEBUG); // 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(); cleanOldVotes(); // Keeps the voter stack tidy delay(500); }