aboutsummaryrefslogtreecommitdiff
path: root/vote/vote.ino
blob: 6d63b715a582dda6e3295e2ce28f904578587b50 (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_INVALID = 1 * 60 * 1000; // 1 minute
  36. // Classify gape state
  37. enum MusselGapState {
  38. Closed,
  39. Open
  40. };
  41. // Data structures
  42. struct Vote {
  43. unsigned long timestamp;
  44. int measure;
  45. };
  46. struct Voter {
  47. String id; // Mussel ID
  48. Vote votes[BALLOT_MAX]; // Last 5 sensor readings
  49. int voteCount = 0; // Number of readings stored
  50. };
  51. // Global array of mussel voters
  52. Voter voters[VOTER_MAX];
  53. int voterCount = 0;
  54. // pointer to control Bluetooth networking
  55. BLEScan *pBLEScan;
  56. /// Find index of mussel ID in the voters array
  57. int findVoterIndex(const String& id) {
  58. for (int i = 0; i < voterCount; i++) {
  59. if (voters[i].id == id) return i;
  60. }
  61. return -1; // Not found
  62. }
  63. /// Add or update vote for a mussel ID
  64. void storeVoteForMussel(
  65. const String& id, unsigned long timestamp, int gape_measure
  66. ) {
  67. int index = findVoterIndex(id);
  68. // If mussel not found, add new
  69. if (index == -1) {
  70. if (voterCount >= VOTER_MAX) {
  71. log_i("Ignored: Max mussel limit reached (%s)",
  72. id.c_str());
  73. return;
  74. }
  75. voters[voterCount].id = id;
  76. voters[voterCount].voteCount = 0;
  77. index = voterCount++;
  78. }
  79. Voter &voter = voters[index];
  80. // Maintain a fixed number of stored votes (FIFO logic)
  81. if (voter.voteCount >= BALLOT_MAX) {
  82. for (int i = 1; i < BALLOT_MAX; i++) {
  83. voter.votes[i - 1] = voter.votes[i];
  84. }
  85. voter.voteCount = BALLOT_MAX - 1;
  86. }
  87. // Store the new vote at the end
  88. voter.votes[voter.voteCount++] = {timestamp, gape_measure};
  89. log_i("Vote stored: Time: %lu, Mussel: %s, Gape: %d",
  90. timestamp, id.c_str(), gape_measure);
  91. }
  92. /// Clean outdated votes from each mussel and remove mussels with no valid votes
  93. void cleanOldVotes() {
  94. unsigned long now = millis();
  95. for (int i = 0; i < voterCount; ) {
  96. Voter &voter = voters[i];
  97. int newCount = 0;
  98. // Shift valid votes to the front
  99. for (int j = 0; j < voter.voteCount; j++) {
  100. unsigned long age = now - voter.votes[j].timestamp;
  101. if (age < VOTE_TIME_INVALID) {
  102. voter.votes[newCount++] = voter.votes[j];
  103. } else {
  104. log_i("Dropped old vote for Mussel %s | Age: %lu ms",
  105. voter.id.c_str(), age);
  106. }
  107. }
  108. voter.voteCount = newCount;
  109. // If all votes are dropped, remove the mussel
  110. if (voter.voteCount == 0) {
  111. log_i("Removing Mussel %s - No valid votes left", voter.id.c_str());
  112. for (int k = i; k < voterCount - 1; k++) {
  113. voters[k] = voters[k + 1];
  114. }
  115. voterCount--;
  116. continue; // Do not increment i since the list shifted
  117. }
  118. i++; // Only increment if we didn't remove current voter
  119. }
  120. }
  121. /// Classify mussel state based on topmost vote
  122. void alignVotes() {
  123. for (int i = 0; i < voterCount; i++) {
  124. Voter &voter = voters[i];
  125. // Skip mussels with no data
  126. if (voter.voteCount == 0) {
  127. log_i("Mussel ID: %s - No data",
  128. voter.id.c_str());
  129. continue;
  130. }
  131. // Use latest vote to determine state
  132. Vote latest = voter.votes[voter.voteCount - 1];
  133. String state = (latest.measure >= 0 && latest.measure < 40)
  134. ? "Closed"
  135. : (latest.measure >= 40 && latest.measure <= 90)
  136. ? "Open"
  137. : "Invalid reading";
  138. log_i("Mussel ID: %s | Latest Gape: %d | State: %s",
  139. voter.id.c_str(), latest.measure, state.c_str());
  140. }
  141. }
  142. /// Decide whether a vote is valid based on age
  143. const char* qualifyMusselVote(
  144. unsigned long voteTimestamp, unsigned long now
  145. ) {
  146. unsigned long age = now - voteTimestamp;
  147. // Log vote age and gape
  148. log_i("Qualifying vote | Time since vote: %lu ms", age);
  149. if (age <= VOTE_TIME_INVALID) {
  150. log_i("→ VALID: Vote is within 1 minute");
  151. return "valid";
  152. } else {
  153. log_i("→ INVALID: Vote is older than 1 minute");
  154. return "invalid";
  155. }
  156. }
  157. /// Output the final vote decision for mussels
  158. void concludeMusselVote() {
  159. int openVotes = 0;
  160. int totalValidVotes = 0;
  161. // First, align votes and qualify them
  162. for (int i = 0; i < voterCount; i++) {
  163. Voter &voter = voters[i];
  164. // Skip mussels with no data
  165. if (voter.voteCount == 0) continue;
  166. // Use latest vote to determine state
  167. Vote latest = voter.votes[voter.voteCount - 1];
  168. unsigned long now = millis();
  169. // Check if the vote is valid using the qualify function
  170. const char* validity = qualifyMusselVote(latest.timestamp, now);
  171. if (strcmp(validity, "valid") == 0) {
  172. totalValidVotes++; // Count valid votes
  173. // Align and classify mussel state
  174. String state = (latest.measure >= 0 && latest.measure < 40)
  175. ? "Closed"
  176. : (latest.measure >= 40 && latest.measure <= 90)
  177. ? "Open"
  178. : "Invalid reading";
  179. // Count valid "Open" votes
  180. if (state == "Open") {
  181. openVotes++;
  182. }
  183. }
  184. }
  185. // Determine the threshold (half of total valid votes, rounded down)
  186. int threshold = totalValidVotes / 2;
  187. // If the number of "Open" votes is greater than or equal to the threshold, water is drinkable
  188. if (openVotes >= threshold) {
  189. log_i("Final Vote: YES (Water is drinkable) - Open Votes: %d, Total Valid Votes: %d",
  190. openVotes, totalValidVotes);
  191. } else {
  192. log_i("Final Vote: NO (Water is not drinkable) - Open Votes: %d, Total Valid Votes: %d",
  193. openVotes, totalValidVotes);
  194. }
  195. }
  196. // Bluetooth beacon discovery callbacks
  197. class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
  198. // decode name and temperature from Eddystone TLM advertisement
  199. void onResult(BLEAdvertisedDevice advertisedDevice) {
  200. if (advertisedDevice.haveName()
  201. && advertisedDevice.getFrameType() == BLE_EDDYSTONE_TLM_FRAME
  202. ) {
  203. BLEEddystoneTLM EddystoneTLM(&advertisedDevice);
  204. // misuse error-only log level for plot-friendly output
  205. #if ARDUHAL_LOG_LEVEL == ARDUHAL_LOG_LEVEL_ERROR
  206. String id_mangled = advertisedDevice.getName();
  207. id_mangled.replace(' ', '_');
  208. id_mangled.replace(':', '=');
  209. Serial.println(id_mangled + ":" + EddystoneTLM.getTemp());
  210. #endif
  211. unsigned long now = millis();
  212. String musselID = advertisedDevice.getName();
  213. int gape = EddystoneTLM.getTemp();
  214. // 1. Store vote
  215. storeVoteForMussel(musselID, now, gape);
  216. // 2. Align
  217. alignVotes();
  218. // 3. Qualify
  219. const char* validity = qualifyMusselVote( now, millis());
  220. // 4. Conclude
  221. concludeMusselVote();
  222. }
  223. }
  224. };
  225. void setup() {
  226. // enable logging to serial
  227. Serial.begin(115200);
  228. esp_log_level_set("*", ESP_LOG_DEBUG);
  229. // setup Bluetooth
  230. BLEDevice::init("");
  231. pBLEScan = BLEDevice::getScan();
  232. pBLEScan->setAdvertisedDeviceCallbacks(
  233. new MyAdvertisedDeviceCallbacks());
  234. pBLEScan->setActiveScan(true);
  235. pBLEScan->setInterval(SCAN_INTERVAL);
  236. pBLEScan->setWindow(SCAN_WINDOW);
  237. }
  238. void loop() {
  239. pBLEScan->start(SCAN_TIME_SEC, false);
  240. pBLEScan->clearResults();
  241. cleanOldVotes(); // Keeps the voter stack tidy
  242. delay(500);
  243. }