aboutsummaryrefslogtreecommitdiff
path: root/Arduino
diff options
context:
space:
mode:
authorJonas Smedegaard <dr@jones.dk>2025-04-18 01:24:05 +0200
committerJonas Smedegaard <dr@jones.dk>2025-04-18 01:24:05 +0200
commit6ca4ff06867aa5d7354615c828bd83234d827ff2 (patch)
tree6a42f307f09e05491f8e1eb9bf157e13916dad8d /Arduino
parentea3988c911b467cbabe38cb9c3e6d0013b53965d (diff)
rop Mussel library; move Arduino sketches below Arduino/
Diffstat (limited to 'Arduino')
-rw-r--r--Arduino/sensor/Mussel_Sensor_Beacon.md35
-rw-r--r--Arduino/sensor/sensor.ino215
-rw-r--r--Arduino/sensor/sensor.puml26
-rw-r--r--Arduino/vote/Mussel_Beacon_Voting.md20
-rw-r--r--Arduino/vote/vote.ino319
-rw-r--r--Arduino/vote/vote.puml27
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