From 6ca4ff06867aa5d7354615c828bd83234d827ff2 Mon Sep 17 00:00:00 2001
From: Jonas Smedegaard <dr@jones.dk>
Date: Fri, 18 Apr 2025 01:24:05 +0200
Subject: rop Mussel library; move Arduino sketches below Arduino/

---
 Arduino/vote/Mussel_Beacon_Voting.md |  20 +++
 Arduino/vote/vote.ino                | 319 +++++++++++++++++++++++++++++++++++
 Arduino/vote/vote.puml               |  27 +++
 3 files changed, 366 insertions(+)
 create mode 100644 Arduino/vote/Mussel_Beacon_Voting.md
 create mode 100644 Arduino/vote/vote.ino
 create mode 100644 Arduino/vote/vote.puml

(limited to 'Arduino/vote')

diff --git a/Arduino/vote/Mussel_Beacon_Voting.md b/Arduino/vote/Mussel_Beacon_Voting.md
new file mode 100644
index 0000000..33a0893
--- /dev/null
+++ b/Arduino/vote/Mussel_Beacon_Voting.md
@@ -0,0 +1,20 @@
+## Mussel Beacon Voting
+
+1. Scans bluetooth network for beacons.
+
+2. Collects mussel name and gape angle
+as decoded from each detected beacon,
+together with the time of detection in milliseconds since boot.
+
+3. Aligns the collected data
+to the format of ballots for a water quality vote.
+
+4. Qualifies the ballots for criteria of the water quality vote
+(e.g. timeliness and sanity of gape angles).
+
+5. Concludes a vote based on collected, aligned and qualified ballots.
+
+6. Acts on the voting result,
+e.g. turns on a steady light for "code green"
+or a blinking light for "code yellow",
+or turns on a blinking light and shuts off a valve for "code red".
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);
+}
diff --git a/Arduino/vote/vote.puml b/Arduino/vote/vote.puml
new file mode 100644
index 0000000..a4958f7
--- /dev/null
+++ b/Arduino/vote/vote.puml
@@ -0,0 +1,27 @@
+@startuml
+:instantiate mussel object;
+:instantiate bluetooth object;
+group init
+:setup mussel voting;
+:setup bluetooth scanner;
+end group
+split
+while (each beacon detected)
+group "bluetooth callback" {
+:collect beacon data;
+end group
+endwhile
+-[hidden]->
+kill
+split again
+while (each 500ms)
+group loop {
+:allign beacon data as ballots;
+:qualify ballots for a vote;
+:conclude vote result;
+:act on vote result;
+end group
+endwhile
+-[hidden]->
+kill
+@enduml
-- 
cgit v1.2.3