aboutsummaryrefslogtreecommitdiff
path: root/Arduino/vote/vote.ino
blob: 3d4b14106232c236ef3b1eac407876a894f48580 (plain)
  1. // SPDX-FileCopyrightText: 2025 Amal Mazrah <mazrah@ruc.dk>
  2. // SPDX-FileCopyrightText: 2025 Jonas Smedegaard <dr@jones.dk>
  3. // SPDX-FileCopyrightText: 2025 Mennatullah Hatim Kassim <stud-mennatulla@ruc.dk>
  4. // SPDX-FileCopyrightText: 2025 Noor Ahmad <noora@ruc.dk>
  5. // SPDX-FileCopyrightText: 2025 Tanishka Suwalka <tanishkas@ruc.dk>
  6. // SPDX-License-Identifier: GPL-3.0-or-later
  7. /// Mussel vote - an Arduino sketch to monitor mussel biosensors
  8. ///
  9. /// @version 0.0.3
  10. /// @see <https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z2tFBF4gN7ziG9oXtUytVQNYe3VhQ>
  11. /// @see <https://moodle.ruc.dk/course/view.php?id=23504>
  12. // arduino-esp32 Logging system
  13. // activate in Arduino IDE: Tools -> Core Debug Level
  14. // special: set Core Debug Level to Error for plot-friendly output
  15. #define CONFIG_ARDUHAL_ESP_LOG 1
  16. #define LOG_LOCAL_LEVEL CORE_DEBUG_LEVEL
  17. #include <esp32-hal-log.h>
  18. #undef ARDUHAL_LOG_FORMAT
  19. #define ARDUHAL_LOG_FORMAT(letter, format) \
  20. ARDUHAL_LOG_COLOR_##letter "[" #letter "] %s(): " format \
  21. ARDUHAL_LOG_RESET_COLOR "\r\n", __FUNCTION__
  22. // arduino-esp32 Bluetooth Low Energy (BLE) networking stack
  23. #include <BLEDevice.h>
  24. #include <BLEScan.h>
  25. #include <BLEAdvertisedDevice.h>
  26. #include <BLEEddystoneTLM.h>
  27. #include <BLEBeacon.h>
  28. #define SCAN_INTERVAL 100
  29. #define SCAN_WINDOW 99
  30. #define SCAN_TIME_SEC 1
  31. // stack sizes for voters and ballots-per-voter
  32. #define VOTER_MAX 10
  33. #define BALLOT_MAX 5
  34. // Validity timing thresholds
  35. const unsigned long VOTE_TIME_TOLERANCE = 1 * 60 * 1000; // 1 minute
  36. // track vote outcome
  37. #define LED_PIN LED_BUILTIN
  38. #define LED1_PIN 2 // Blue
  39. #define LED2_PIN 33 // Green
  40. bool waterIsDrinkable = true; // true means LED off, false means LED on
  41. // Classify gape state
  42. enum MusselGapState {
  43. Closed,
  44. Open
  45. };
  46. // Data structures
  47. struct Vote {
  48. unsigned long timestamp;
  49. int measure;
  50. };
  51. struct Voter {
  52. String id; // Mussel ID
  53. Vote votes[BALLOT_MAX]; // Last 5 sensor readings
  54. int voteCount = 0; // Number of readings stored
  55. };
  56. // Global array of mussel voters
  57. Voter voters[VOTER_MAX];
  58. int voterCount = 0;
  59. // pointer to control Bluetooth networking
  60. BLEScan *pBLEScan;
  61. /// Find index of mussel ID in the voters array
  62. int findVoterIndex(const String& id) {
  63. for (int i = 0; i < voterCount; i++) {
  64. if (voters[i].id == id) return i;
  65. }
  66. return -1; // Not found
  67. }
  68. /// Add or update data for a mussel voting ballot
  69. void collectBallotData(
  70. const String& id, unsigned long timestamp, int gape_measure
  71. ) {
  72. int index = findVoterIndex(id);
  73. // If mussel not found, add new
  74. if (index == -1) {
  75. if (voterCount >= VOTER_MAX) {
  76. log_i("Ignored: Max mussel limit reached (%s)",
  77. id.c_str());
  78. return;
  79. }
  80. voters[voterCount].id = id;
  81. voters[voterCount].voteCount = 0;
  82. index = voterCount++;
  83. }
  84. Voter &voter = voters[index];
  85. // Maintain a fixed number of stored ballots (FIFO logic)
  86. if (voter.voteCount >= BALLOT_MAX) {
  87. for (int i = 1; i < BALLOT_MAX; i++) {
  88. voter.votes[i - 1] = voter.votes[i];
  89. }
  90. voter.voteCount = BALLOT_MAX - 1;
  91. }
  92. // Store the new draft ballot at the end
  93. voter.votes[voter.voteCount++] = {timestamp, gape_measure};
  94. log_i("Vote stored: Time: %lu, Mussel: %s, Gape: %d",
  95. timestamp, id.c_str(), gape_measure);
  96. }
  97. /// Clean outdated voting ballots and mussels with no valid ballots
  98. void cleanOldBallotData() {
  99. unsigned long now = millis();
  100. for (int i = 0; i < voterCount; ) {
  101. Voter &voter = voters[i];
  102. int newCount = 0;
  103. // Shift valid ballots to the front
  104. for (int j = 0; j < voter.voteCount; j++) {
  105. unsigned long age = now - voter.votes[j].timestamp;
  106. if (age < VOTE_TIME_TOLERANCE) {
  107. voter.votes[newCount++] = voter.votes[j];
  108. } else {
  109. log_i("Dropped old ballot for Mussel %s (age: %lums)",
  110. voter.id.c_str(), age);
  111. }
  112. }
  113. voter.voteCount = newCount;
  114. // If all ballots are dropped, remove the mussel
  115. if (voter.voteCount == 0) {
  116. log_i("Removing Mussel %s - No valid ballots left",
  117. voter.id.c_str());
  118. for (int k = i; k < voterCount - 1; k++) {
  119. voters[k] = voters[k + 1];
  120. }
  121. voterCount--;
  122. continue; // Do not increment i since the list shifted
  123. }
  124. i++; // Only increment if we didn't remove current voter
  125. }
  126. }
  127. /// Classify mussel state based on topmost vote
  128. void alignVotes() {
  129. for (int i = 0; i < voterCount; i++) {
  130. Voter &voter = voters[i];
  131. // Skip mussels with no data
  132. if (voter.voteCount == 0) {
  133. log_i("Mussel ID: %s - No data",
  134. voter.id.c_str());
  135. continue;
  136. }
  137. // Use latest vote to determine state
  138. Vote latest = voter.votes[voter.voteCount - 1];
  139. String state = (latest.measure >= 0 && latest.measure < 40)
  140. ? "Closed"
  141. : (latest.measure >= 40 && latest.measure <= 90)
  142. ? "Open"
  143. : "Invalid reading";
  144. log_i("Mussel ID: %s | Latest Gape: %d | State: %s",
  145. voter.id.c_str(), latest.measure, state.c_str());
  146. }
  147. }
  148. /// Decide whether a voting ballot is valid based on age
  149. const char* qualifyBallot(
  150. unsigned long voteTimestamp, unsigned long now
  151. ) {
  152. unsigned long age = now - voteTimestamp;
  153. if (age <= VOTE_TIME_TOLERANCE) {
  154. log_i("VALID: Ballot is within 1 minute (age: %lums)",
  155. age);
  156. return "valid";
  157. } else {
  158. log_i("INVALID: Ballot is older than 1 minute (age: %lums)",
  159. age);
  160. return "invalid";
  161. }
  162. }
  163. /// Resolve the outcome of the voting procedure
  164. void concludeVote() {
  165. int openVotes = 0;
  166. int totalValidVotes = 0;
  167. // First, align votes and qualify them
  168. for (int i = 0; i < voterCount; i++) {
  169. Voter &voter = voters[i];
  170. // Skip mussels with no data
  171. if (voter.voteCount == 0) continue;
  172. // Use latest vote to determine state
  173. Vote latest = voter.votes[voter.voteCount - 1];
  174. unsigned long now = millis();
  175. // Check if the vote is valid using the qualify function
  176. const char* validity = qualifyBallot(latest.timestamp, now);
  177. if (strcmp(validity, "valid") == 0) {
  178. totalValidVotes++; // Count valid votes
  179. // Align and classify mussel state
  180. String state = (latest.measure >= 0 && latest.measure < 40)
  181. ? "Closed"
  182. : (latest.measure >= 40 && latest.measure <= 90)
  183. ? "Open"
  184. : "Invalid reading";
  185. // Count valid "Open" votes
  186. if (state == "Open") {
  187. openVotes++;
  188. }
  189. }
  190. }
  191. // Determine the threshold (half of total valid votes, rounded down)
  192. int threshold = totalValidVotes / 2;
  193. waterIsDrinkable = (openVotes >= threshold);
  194. // Same or more "Open" votes than the threshold means water is ok
  195. if (openVotes >= threshold) {
  196. log_i("YES, water is drinkable (open: %d, valid: %d)",
  197. openVotes, totalValidVotes);
  198. } else {
  199. log_i("NO, water is not drinkable (open: %d, valid: %d)",
  200. openVotes, totalValidVotes);
  201. }
  202. }
  203. // Bluetooth beacon discovery callbacks
  204. class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
  205. // decode name and temperature from Eddystone TLM advertisement
  206. void onResult(BLEAdvertisedDevice advertisedDevice) {
  207. if (advertisedDevice.haveName()
  208. && advertisedDevice.getFrameType() == BLE_EDDYSTONE_TLM_FRAME
  209. ) {
  210. BLEEddystoneTLM EddystoneTLM(&advertisedDevice);
  211. // misuse error-only log level for plot-friendly output
  212. #if ARDUHAL_LOG_LEVEL == ARDUHAL_LOG_LEVEL_ERROR
  213. String id_mangled = advertisedDevice.getName();
  214. id_mangled.replace(' ', '_');
  215. id_mangled.replace(':', '=');
  216. Serial.println(id_mangled + ":" + EddystoneTLM.getTemp());
  217. #endif
  218. unsigned long now = millis();
  219. String musselID = advertisedDevice.getName();
  220. int gape = EddystoneTLM.getTemp();
  221. collectBallotData(musselID, now, gape);
  222. }
  223. }
  224. };
  225. void setup() {
  226. // enable logging to serial
  227. Serial.begin(115200);
  228. esp_log_level_set("*", ESP_LOG_DEBUG);
  229. // start with LED off (HIGH = off on active-low boards)
  230. pinMode(LED_PIN, OUTPUT);
  231. digitalWrite(LED_PIN, HIGH);
  232. pinMode(LED1_PIN, OUTPUT);
  233. pinMode(LED2_PIN, OUTPUT);
  234. digitalWrite(LED1_PIN, LOW);
  235. digitalWrite(LED2_PIN, LOW);
  236. // setup Bluetooth
  237. BLEDevice::init("");
  238. pBLEScan = BLEDevice::getScan();
  239. pBLEScan->setAdvertisedDeviceCallbacks(
  240. new MyAdvertisedDeviceCallbacks());
  241. pBLEScan->setActiveScan(true);
  242. pBLEScan->setInterval(SCAN_INTERVAL);
  243. pBLEScan->setWindow(SCAN_WINDOW);
  244. }
  245. void loop() {
  246. pBLEScan->start(SCAN_TIME_SEC, false);
  247. pBLEScan->clearResults();
  248. alignVotes();
  249. concludeVote();
  250. cleanOldBallotData(); // Tidy the voter stack
  251. digitalWrite(LED_PIN, waterIsDrinkable ? LOW : HIGH);
  252. // LED Logic: GREEN if drinkable, RED if not
  253. if (waterIsDrinkable) {
  254. digitalWrite(LED2_PIN, HIGH); // GREEN ON
  255. digitalWrite(LED1_PIN, LOW); // RED OFF
  256. Serial.println("Water is DRINKABLE - GREEN LED ON");
  257. } else {
  258. digitalWrite(LED2_PIN, LOW); // GREEN OFF
  259. digitalWrite(LED1_PIN, HIGH); // RED ON
  260. Serial.println("Water is NOT DRINKABLE - RED LED ON");
  261. }
  262. delay(500);
  263. }