diff options
author | Jonas Smedegaard <dr@jones.dk> | 2025-04-18 01:24:05 +0200 |
---|---|---|
committer | Jonas Smedegaard <dr@jones.dk> | 2025-04-18 01:24:05 +0200 |
commit | 6ca4ff06867aa5d7354615c828bd83234d827ff2 (patch) | |
tree | 6a42f307f09e05491f8e1eb9bf157e13916dad8d /Arduino | |
parent | ea3988c911b467cbabe38cb9c3e6d0013b53965d (diff) |
rop Mussel library; move Arduino sketches below Arduino/
Diffstat (limited to 'Arduino')
-rw-r--r-- | Arduino/sensor/Mussel_Sensor_Beacon.md | 35 | ||||
-rw-r--r-- | Arduino/sensor/sensor.ino | 215 | ||||
-rw-r--r-- | Arduino/sensor/sensor.puml | 26 | ||||
-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 |
6 files changed, 642 insertions, 0 deletions
diff --git a/Arduino/sensor/Mussel_Sensor_Beacon.md b/Arduino/sensor/Mussel_Sensor_Beacon.md new file mode 100644 index 0000000..89beeec --- /dev/null +++ b/Arduino/sensor/Mussel_Sensor_Beacon.md @@ -0,0 +1,35 @@ +# Mussel Sensor Beacon + +1. Reads sensors. + +2. Normalises sensor data as a gape angle in the range of 0°-90°. + +3. Broadcasts normalised sensor data as a beacon on a bluetooth network. + +## Scanner apps + +These Android apps have been found usable +for monitoring this type of sensor: + + * FeasyBeacon on Play store + * NanoBeacon BLE Scanner on Play store + * decodes Eddystone TLM temperature as Fahrenheit + * does not decode Eddystone URL + * SemBeacon on Play store + * detects URL-only but not TLM-only Eddystone Beacon + * too aggressive caching misses changing data + * UI optimized for SemBeacon + * nRF Connect for Mobile on Play store + * misses changing data + * UI not specific to beacons + +These Android apps are potentially interesting as well: + + * Beacon Locator on F-droid + * unreliable detection + * active development + * BLE Radar on F-droid + * detects URL-only but not TLM-only Eddystone Beacon + * UI not specific to beacons + * AltBeacon Loc on Play store + * detects URL-only but not TLM-only Eddystone Beacon diff --git a/Arduino/sensor/sensor.ino b/Arduino/sensor/sensor.ino new file mode 100644 index 0000000..8d43b22 --- /dev/null +++ b/Arduino/sensor/sensor.ino @@ -0,0 +1,215 @@ +// 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 + +/// Sensor mussel - an Arduino sketch to emulate a mussel biosensor +/// +/// @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 "BLEBeacon.h" +#include "BLEAdvertising.h" +#include "BLEEddystoneTLM.h" + +// Adjust these for production use +// +// * BEACON_NAME must be unique within deployment +// * BEACON_UUID should be unique for each deployment +// +// @see https://www.uuidgenerator.net/ +#define BEACON_NAME "Dummy mussel sensor" +#define BEACON_UUID "00000000-0000-0000-0000-000000000000" + +// maximum accumulated stress +#define STRESS_MAX 50 + +// light sensor +#define LIGHT_PIN 34 +#define DARKNESS_MAX 1000 + +// arduino-esp32 Touch sensor +#define TOUCH_PIN T0 // T0 is GPIO4 +#define TOUCH_THRESHOLD 40 + +// arduino-esp32 LED PWM Controller (LEDC) as pacemaker for gaping rhythm +#define LED_PIN LED_BUILTIN +#define LEDC_BITS 7 +#define LEDC_FREQ 500 +#define LEDC_START_DUTY 0 +#define LEDC_TARGET_DUTY 90 +#define LEDC_CALM_PACE 3000 +#define LEDC_STRESSED_PACE 400 + +// pacemaker variables +int stress = 0; +bool touch_detected = false; +int pace = LEDC_STRESSED_PACE; +bool fade_ended = false; +bool fade_in = true; + +// pointer to control Bluetooth networking +BLEAdvertising *pAdvertising; + +// Touch sensor callback +void gotTouch() { +// keepPace(); + touch_detected = true; + pace = LEDC_STRESSED_PACE; +} + +// pacemaker end-of-fade Interrupt Service Routine (ISR) a.k.a. callback +void ARDUINO_ISR_ATTR LED_FADE_ISR() { + fade_ended = true; + keepPace(); +} + +// stress-inducing touch callback +void beginTouchDetection() { + touchAttachInterrupt(TOUCH_PIN, gotTouch, TOUCH_THRESHOLD); + log_d("touch detected"); +} + +// pacemaker initialization +void beginPace() { + + // Setup pacemaker timer + ledcAttach(LED_PIN, LEDC_FREQ, LEDC_BITS); + + // fade in once uncontrolled and then begin fade out with ISR + ledcFade(LED_PIN, LEDC_START_DUTY, LEDC_TARGET_DUTY, pace); + delay(pace); + ledcFadeWithInterrupt(LED_PIN, LEDC_TARGET_DUTY, LEDC_START_DUTY, + pace, LED_FADE_ISR); +} + +// pacemaker maintenance +void keepPace() { + +// if (fade_ended || touch_detected) { + if (fade_ended) { + fade_ended = false; + + // stress management + if (touch_detected) { + touch_detected = false; + log_i("Stressed by touch!"); + if (stress < STRESS_MAX) { + stress = stress + 10; + } + } else if (stress > 0) { + stress--; + if (stress <= 0) { + pace = LEDC_CALM_PACE; + log_i("Calmed down..."); + } else { + log_i("Still stressed..."); + } + } else { + pace = LEDC_CALM_PACE; + } + + // begin fade at decided direction and pace + ledcFadeWithInterrupt(LED_PIN, + fade_in ? LEDC_START_DUTY : LEDC_TARGET_DUTY, + fade_in ? LEDC_TARGET_DUTY : LEDC_START_DUTY, + pace, LED_FADE_ISR); + + // remember next fade direction + fade_in = !fade_in; + } +} + +// read light intensity and return its non-zero capped value +int getLightIntensity() { + int value = analogRead(LIGHT_PIN); + if (value > DARKNESS_MAX) + value = DARKNESS_MAX; + log_i("light intensity: %d", value); + + return DARKNESS_MAX - value; +} + +// fake gape angle as pacemaker position dampened by light intensity +int resolveGapeAngle() { + int paceAngle = ledcRead(LED_PIN); + log_i("pacemaker value: %d", value); + + int lightIntensity = getLightIntensity(); + log_i("light intensity: %d", value); + + int gapeAngle = paceAngle * lightIntensity / DARKNESS_MAX; + + // misuse error-only log level for plot-friendly output +#if ARDUHAL_LOG_LEVEL == ARDUHAL_LOG_LEVEL_ERROR + Serial.printf("pace_angle:%d light/10:%d gape_angle:%d\n", + paceAngle, lightIntensity/10, gapeAngle); +#endif + + return gapeAngle; +} + +// Encode static Bluetooth beacon advertisement data +void setBeaconAdvertisement() { + BLEAdvertisementData oAdvertisementData = BLEAdvertisementData(); + oAdvertisementData.setName(BEACON_NAME); + pAdvertising->setAdvertisementData(oAdvertisementData); +} + +// Encode variable Bluetooth beacon service data +void setBeaconServiceData(int angle) { + BLEEddystoneTLM EddystoneTLM; + EddystoneTLM.setTemp(angle); + log_i("Gape angle: %.2f°", EddystoneTLM.getTemp()); + + BLEAdvertisementData oScanResponseData = BLEAdvertisementData(); + oScanResponseData.setServiceData( + BLEUUID((uint16_t)0xFEAA), + String( + EddystoneTLM.getData().c_str(), + EddystoneTLM.getData().length())); + pAdvertising->setScanResponseData(oScanResponseData); +} + +void setup() { + + // enable logging to serial + Serial.begin(115200); + esp_log_level_set("*", ESP_LOG_DEBUG); + if (BEACON_UUID == "00000000-0000-0000-0000-000000000000") + Serial.println("Please set a deployment-wide unique BEACON_UUID"); + + beginPace(); + beginTouchDetection(); + + // setup Bluetooth + BLEDevice::init(BEACON_NAME); + pAdvertising = BLEDevice::getAdvertising(); + setBeaconAdvertisement(); + setBeaconServiceData(resolveGapeAngle()); + pAdvertising->start(); +} + +void loop() { + + // update Bluetooth beacon service data + setBeaconServiceData(resolveGapeAngle()); + + delay(500); +} diff --git a/Arduino/sensor/sensor.puml b/Arduino/sensor/sensor.puml new file mode 100644 index 0000000..3f033d1 --- /dev/null +++ b/Arduino/sensor/sensor.puml @@ -0,0 +1,26 @@ +@startuml +'start +:instantiate mussel object; +:instantiate bluetooth object; +group init +:setup mussel sensors; +:setup bluetooth beacon; +end group +split +while (each 500ms) +group loop { +:read sensors; +:normalize sensor data +as a gape angle; +:add gape angle to beacon; +end group +endwhile +-[hidden]-> +kill +split again +while (each 100ms) +:broadcast beacon; +endwhile +-[hidden]-> +kill +@enduml 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 |