aboutsummaryrefslogtreecommitdiff
path: root/Arduino/vote/vote.ino
diff options
context:
space:
mode:
Diffstat (limited to 'Arduino/vote/vote.ino')
-rw-r--r--Arduino/vote/vote.ino319
1 files changed, 319 insertions, 0 deletions
diff --git a/Arduino/vote/vote.ino b/Arduino/vote/vote.ino
new file mode 100644
index 0000000..3d4b141
--- /dev/null
+++ b/Arduino/vote/vote.ino
@@ -0,0 +1,319 @@
+// 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_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);
+}