- // SPDX-FileCopyrightText: 2025 Amal Mazrah <mazrah@ruc.dk>
- // SPDX-FileCopyrightText: 2025 Jonas Smedegaard <dr@jones.dk>
- // SPDX-FileCopyrightText: 2025 Mennatullah Hatim Kassim <stud-mennatulla@ruc.dk>
- // SPDX-FileCopyrightText: 2025 Noor Ahmad <noora@ruc.dk>
- // SPDX-FileCopyrightText: 2025 Tanishka Suwalka <tanishkas@ruc.dk>
- // SPDX-License-Identifier: GPL-3.0-or-later
- /// Mussel vote - an Arduino sketch to monitor mussel biosensors
- ///
- /// @version 0.0.3
- /// @see <https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z2tFBF4gN7ziG9oXtUytVQNYe3VhQ>
- /// @see <https://moodle.ruc.dk/course/view.php?id=23504>
- // 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 <esp32-hal-log.h>
- #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 <BLEDevice.h>
- #include <BLEScan.h>
- #include <BLEAdvertisedDevice.h>
- #include <BLEEddystoneTLM.h>
- #include <BLEBeacon.h>
- #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_AHEAD = 1 * 60 * 1000; // 1 minute
- const unsigned long VOTE_TIME_BEHIND = 2 * 60 * 1000; // 2 minutes
- // 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);
- }
- /// 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 gape and age
- const char* qualifyMusselVote(
- int gape, unsigned long voteTimestamp, unsigned long now
- ) {
- // Determine state based on gape
- MusselGapState gapState = gape >= 40 && gape <= 90 ? Open : Closed;
- const char* gapStateStr = (gapState == Open) ? "Open" : "Closed";
- unsigned long age = now - voteTimestamp;
- // Log the state
- log_i("Qualifying vote | Time since vote: %lu ms | Gape: %d (%s)",
- age, gape, gapStateStr);
- // Invalid if mussel is closed
- if (gapState == Closed) {
- log_i("→ INVALID: Mussel is Closed");
- return "invalid";
- }
- // Invalid if vote is too old
- if (age > VOTE_TIME_BEHIND) {
- log_i("→ INVALID: Vote is too old (>2 minutes)");
- return "invalid";
- }
- // Valid if within 1 minute and mussel is open
- if (age <= VOTE_TIME_AHEAD) {
- log_i("→ VALID: Mussel is Open and vote is recent");
- return "valid";
- }
- // Catch-all for anything in between
- log_i("→ INVALID: Vote is in uncertain window time");
- return "invalid";
- }
- /// Output the final vote decision for a mussel
- void concludeMusselVote(const String& musselId, const char* validity) {
- const char* result = strcmp(validity, "valid") == 0 ? "YES" : "NO";
- log_i("Final Vote from Mussel %s → %s (Vote was %s)",
- musselId.c_str(), result, validity);
- }
- // 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(gape, now, millis());
- // 4. Conclude
- concludeMusselVote(musselID, validity);
- }
- }
- };
- 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();
- delay(500);
- }
|