diff options
Diffstat (limited to 'Arduino/vote')
-rw-r--r-- | Arduino/vote/Mussel_Beacon_Voting.md | 20 | ||||
-rw-r--r-- | Arduino/vote/vote.ino | 319 | ||||
-rw-r--r-- | Arduino/vote/vote.puml | 27 |
3 files changed, 366 insertions, 0 deletions
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 |