Cycling Comfort and Road Damage 2026


1. Hardware Overview

1.1 Microcontroller: Seeeduino LoRaWAN 

Seeeduino LoRaWAN is an Arduino development board with LoRaWan protocol embedded, through which you can get started quickly to experience LoRa's advantage in the field of IoT. Based on the communication module RHF76-052AM, Seeeduino LoRaWAN is compatible with LoRaWAN Class A/C and supports a variety of communication frequencies.

https://wiki.seeedstudio.com/Seeeduino_LoRAWAN/


1.2 Base Shield

Arduino Uno is the most popular Arduino board so far, however it is sometimes frustrating when your project requires a lot of sensors or Leds and your jumper wires are in a mess. The purpose of creating the Base Shield is to help you get rid of bread board and jumper wires. With the rich grove connectors on the base board, you can add all the grove modules to the Arduino Uno conveniently! The pinout of Base Shield V2 is the same as Arduino Uno R3.

https://wiki.seeedstudio.com/Base_Shield_V2/


1.3 Grove - Vibration Sensor(SW-420)

The Grove - Vibration Sensor (SW-420) is a high sensitivity non-directional vibration sensor. When the module is stable, the circuit is turned on and the output is high. When the movement or vibration occurs, the circuit will be briefly disconnected and output low. At the same time, you can also adjust the sensitivity according to your own needs.

https://wiki.seeedstudio.com/Grove-Vibration_Sensor_SW-420/
https://github.com/efduarte/pincello/blob/master/sensor-vibration-sw-420.md


1.4 Grove - GPS (Air530 / Air530Z)

Confused of your GPS not working well in urbans or outsides under only one or few statelite module? Then you should not miss our new Grove-GPS (Air530 / Air530Z). It’s a high-performance, highly integrated multi-mode statelite positioning and navigation module. It supports GPS / Beidou / Glonass / Galileo / QZSS / SBAS, which makes it suitable for GNSS positioning applications such as car navigation, smart wear and drone.

https://wiki.seeedstudio.com/Grove-GPS-Air530/

https://docs.arduino.cc/libraries/tinygps/

https://www.u-blox.com/en/product/u-center


1.5 Grove - 6-Axis Accelerometer&Gyroscope

Grove - 6-Axis Accelerometer&Gyroscope is a cost-effective Grove interfaced and integrated sensor combination of 3-axis digital accelerometer and 3-axis digital gyroscope.

With a serious low power consumption digital chip LSM6DS3(datasheet) and power supply regulator inside, it features high sensitivity, green tech and low noise interference. It can be configured to different sensitivity levels of acceleration and different angular rate measurement range. Provided with detailed SDK, it can make the prototyping process quicker and easier.

https://wiki.seeedstudio.com/Grove-6-Axis_AccelerometerAndGyroscope/

https://github.com/Seeed-Studio/Grove_6Axis_Accelerometer_And_Gyroscope_BMI088


1.6 Temperature and Humidity !

This is a powerful sister version of our Grove - Temperature&Humidity Sensor Pro. It has more complete and accurate performance than the basic version. The detecting range of this sensor is 5% RH - 99% RH, and -40°C - 80°C. And its accuracy reaches up to 2% RH and 0.5°C. A professional choice for applications that have relatively strict requirements.

https://wiki.seeedstudio.com/Grove-Temperature_and_Humidity_Sensor_Pro/


1.7 Antenna 

Attach Antenna for better network connection.

Seeeduino LoraWAN


2. Implementation 


The Grove GPS (Air530 / Air530Z) module is used to obtain location data such as latitude and longitude. After connecting the sensor, the data can be monitored through the Serial Monitor.



Connect the Grove Vibration Sensor (SW-420) and observe the output in the Serial Monitor.


Hardware setup: connecting the Seeeduino LoRaWAN, Base Shield, and cables


First, install the Arduino IDE. Then connect the board via USB, select the appropriate board and port, include the necessary libraries or write your code, upload the program, and finally open the Serial Monitor to view the output.


Seeeduino LoraWAN:

// Seeeduino LoRaWAN ------------------------------------------------------------
#define PIN_GROVE_POWER 38
#define SerialUSB Serial



// LoRaWAN -----------------------------------------------------------------------
#include <LoRaWan.h>

// Put your LoRa keys here
#define DevEUI "8765182202E81E06"
#define AppEUI "E89CE2FC9061426D"
#define AppKey "37AED259E27AF6F849410C86558E4B90"

// SETUP -------------------------------------------------------------------------
// vars
char buffer[256];
void setup(void)
{
  // Setup Serial connection
  delay(5000);

  Serial.begin(115200);

  // Powerup Seeeduino LoRaWAN Grove connectors
  pinMode(PIN_GROVE_POWER, OUTPUT);
  digitalWrite(PIN_GROVE_POWER, 1);

  // Config LoRaWAN
  lora.init();

  memset(buffer, 0, 256);
  lora.getVersion(buffer, 256, 1);
  if (Serial) {
    Serial.print(buffer);
  }

  memset(buffer, 0, 256);
  lora.getId(buffer, 256, 1);
  if (Serial) {
    Serial.print(buffer);
  }

  // void setId(char *DevAddr, char *DevEUI, char *AppEUI);
  // replace the xxxxxx and the yyyyyy below with the DevEUI and the
  // AppEUI obtained from your registered sensor node and application
  // in The Things Network (TTN). The numbers are hexadecimal strings
  // without any leading prefix like "0x" and must have exactly the
  // same number of characters as given below.
  // lora.setId(NULL, "xxxxxxxxxxxxxxxx", "yyyyyyyyyyyyyyyy");
  lora.setId(NULL, DevEUI, AppEUI);

  // setKey(char *NwkSKey, char *AppSKey, char *AppKey);
  // replace the zzzzzz below with the AppKey obtained from your registered
  // application in The Things Network (TTN). The numbers are hexadecimal
  // strings without any leading prefix like "0x" and must have exactly
  // the same number of characters as given below.
  // lora.setKey(NULL, NULL, "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz");
  lora.setKey(NULL, NULL, AppKey);

  lora.setDeciveMode(LWOTAA);
  lora.setDataRate(DR0, EU868);      // DR5 = SF7, DR0 = SF 12
  lora.setAdaptiveDataRate(true);

  lora.setChannel(0, 868.1);
  lora.setChannel(1, 868.3);
  lora.setChannel(2, 868.5);
  lora.setChannel(3, 867.1);
  lora.setChannel(4, 867.3);
  lora.setChannel(5, 867.5);
  lora.setChannel(6, 867.7);
  lora.setChannel(7, 867.9);

  lora.setDutyCycle(false);
  lora.setJoinDutyCycle(false);

  lora.setPower(14);
  lora.setPort(33);

  unsigned int nretries;
  nretries = 0;
  while (!lora.setOTAAJoin(JOIN, 20)) {
    nretries++;
    if (Serial) {
      Serial.println((String)"Join failed, retry: " + nretries);
    }
  }
  Serial.println("Join successful!");
}

// LOOP --------------------------------------------------------------------
unsigned int nloops = 0;
void loop(void) {
  nloops++;
  if (Serial) {
    Serial.println((String)"Loop " + nloops + "...");
  }

  bool result = false;
  unsigned char data[1];
  data[0] = '0';

  // Transfer LoRa package
  result = lora.transferPacket(data, 1, 5);
  // result = lora.transferPacketWithConfirmed(data, 1, 5);

  if (result) {
    short length;
    short rssi;

    // Receive LoRaWAN package (LoraWAN Class A)
    char rx[256];
    length = lora.receivePacket(rx, 256, &rssi);

    // Check, if a package was received
    if (length)
    {
      if (Serial) {
        Serial.print("Length is: ");
        Serial.println(length);
        Serial.print("RSSI is: ");
        Serial.println(rssi);
        Serial.print("Data is: ");

        // Print received data as HEX
        for (unsigned char i = 0; i < length; i ++)
        {
          Serial.print("0x");
          Serial.print(rx[i], HEX);
          Serial.print(" ");
        }

        // Convert received package to int
        int rx_data_asInteger = atoi(rx);

        Serial.println();
        Serial.println("Received data: " + String(rx_data_asInteger));
      }
    }
  }

  if (Serial) {
    Serial.println((String)"Loop " + nloops + "...done!\n");
  }

  // Wait for 30s
  delay(30000);
}

Code source: Seeeduino LoraWAN


Cayenne Protocol

error occured:


Code source: https://collab.dvb.bayern/spaces/TUMgeosensorweb/pages/73809214/Cayenne+Protocol



Next: https://console.cloud.thethings.network/


Registered on The Things Network, created a new application. Then, registered the end device:

DevEUI was taken from what's written on the device, and AppKey was generated by the platform.


Tried connecting to the project from home of one of the teammates, it didn't work - the joins didn't succeed, even when retrying 30+ times. It was tested both indoors and outdoors.


On campus, the connection worked:


Next step - try to add the accelerometer to the setup and get its data sending to the server.

Install the LSM6DS3 library:



Update the connection test code to enable sending test data from the accelerometer:

// Seeeduino LoRaWAN ------------------------------------------------------------
#define PIN_GROVE_POWER 38
#define SerialUSB Serial



// LoRaWAN -----------------------------------------------------------------------
#include <LoRaWan.h>

// Accelerometer -----------------------------------------------------------------
#include <LSM6DS3.h>
#include <Wire.h>
LSM6DS3 myIMU(I2C_MODE, 0x6A);

// CayenneLPP --------------------------------------------------------------------
#include <CayenneLPP.h>
CayenneLPP lpp(51);

// Keys --------------------------------------------------------------------------
#define DevEUI "8CF9572000056A44"
#define AppEUI "0000000000000000"
#define AppKey "6EFC7CA780C89CE809673BB3CBFC2833"

// SETUP -------------------------------------------------------------------------
char buffer[256];
void setup(void)
{
delay(5000);
Serial.begin(115200);

// Powerup Grove connectors
pinMode(PIN_GROVE_POWER, OUTPUT);
digitalWrite(PIN_GROVE_POWER, 1);

// Init IMU
if (myIMU.begin() != 0) {
Serial.println("IMU error!");
} else {
Serial.println("IMU OK!");
}

// Config LoRaWAN
lora.init();

memset(buffer, 0, 256);
lora.getVersion(buffer, 256, 1);
if (Serial) Serial.print(buffer);

memset(buffer, 0, 256);
lora.getId(buffer, 256, 1);
if (Serial) Serial.print(buffer);

lora.setId(NULL, DevEUI, AppEUI);
lora.setKey(NULL, NULL, AppKey);
lora.setDeciveMode(LWOTAA);
lora.setDataRate(DR0, EU868);
lora.setAdaptiveDataRate(true);

lora.setChannel(0, 868.1);
lora.setChannel(1, 868.3);
lora.setChannel(2, 868.5);
lora.setChannel(3, 867.1);
lora.setChannel(4, 867.3);
lora.setChannel(5, 867.5);
lora.setChannel(6, 867.7);
lora.setChannel(7, 867.9);

lora.setDutyCycle(false);
lora.setJoinDutyCycle(false);
lora.setPower(14);
lora.setPort(33);

unsigned int nretries = 0;
while (!lora.setOTAAJoin(JOIN, 20)) {
nretries++;
if (Serial) Serial.println((String)"Join failed, retry: " + nretries);
}
Serial.println("Join successful!");
}

// LOOP --------------------------------------------------------------------
unsigned int nloops = 0;
void loop(void) {
nloops++;
if (Serial) Serial.println((String)"Loop " + nloops + "...");

// Read accelerometer
float ax = myIMU.readFloatAccelX();
float ay = myIMU.readFloatAccelY();
float az = myIMU.readFloatAccelZ();

Serial.print("Accel X: "); Serial.print(ax);
Serial.print(" Y: "); Serial.print(ay);
Serial.print(" Z: "); Serial.println(az);

// Encode with CayenneLPP
lpp.reset();
lpp.addAccelerometer(1, ax, ay, az); // channel 1

// Send
bool result = lora.transferPacket(lpp.getBuffer(), lpp.getSize(), 5);

if (result) {
short length, rssi;
char rx[256];
length = lora.receivePacket(rx, 256, &rssi);
if (length) {
Serial.print("Downlink RSSI: "); Serial.println(rssi);
}
}

if (Serial) Serial.println((String)"Loop " + nloops + "...done!\n");

delay(30000);
}

Source: generated by claude.ai with reference to the original code for testing connection to the server.


The code seems to be working, the accelerometer measurements show up in both the serial monitor, and in TTN application live data:


Right now in the application, i saw the payload as base64, so configuring the CayenneLPP decoder for the uplink messages:


Now the payloads get decoded in a human-readable format:


Going for a test ride on the bike. For this test, a Mac is the power source. To make it possible to close the mac lid and not kill the USB connection, mac sleep mode has to be disabled while doing the test drive with the following command:

sudo pmset -b disablesleep 1

And after the test, it's enabled again with the opposite command:

sudo pmset -b disablesleep 0


Now, adding also GPS, Vibration and Temperature/Humidity sensors. Install the required libraries:


And update the code to also send data from these 3 sensors:

// Seeeduino LoRaWAN ------------------------------------------------------------
#define PIN_GROVE_POWER 38
#define SerialUSB Serial

// LoRaWAN -----------------------------------------------------------------------
#include <LoRaWan.h>

// Accelerometer -----------------------------------------------------------------
#include <LSM6DS3.h>
#include <Wire.h>
LSM6DS3 myIMU(I2C_MODE, 0x6A);

// CayenneLPP --------------------------------------------------------------------
#include <CayenneLPP.h>
CayenneLPP lpp(51);

// Vibration ---------------------------------------------------------------------
#define PIN_VIBRATION A0

// Humidity ----------------------------------------------------------------------
#include <DHT.h>
#define PIN_DHT D4
#define DHTTYPE DHT22
DHT dht(PIN_DHT, DHTTYPE);

// GPS ---------------------------------------------------------------------------
#include <TinyGPS++.h>
#include <SoftwareSerial.h>
SoftwareSerial gpsSerial(D2, D3); // RX, TX
TinyGPSPlus gps;

// Keys --------------------------------------------------------------------------
#define DevEUI "8CF9572000056A44"
#define AppEUI "0000000000000000"
#define AppKey "6EFC7CA780C89CE809673BB3CBFC2833"

// SETUP -------------------------------------------------------------------------
char buffer[256];
void setup(void)
{
  delay(5000);
  Serial.begin(115200);

  // Powerup Grove connectors
  pinMode(PIN_GROVE_POWER, OUTPUT);
  digitalWrite(PIN_GROVE_POWER, 1);

  // Init vibration
  pinMode(PIN_VIBRATION, INPUT);

  // Init humidity
  dht.begin();

  // Init GPS
  gpsSerial.begin(9600);

  // Init IMU
  if (myIMU.begin() != 0) {
    Serial.println("IMU error!");
  } else {
    Serial.println("IMU OK!");
  }

  // Config LoRaWAN
  lora.init();

  memset(buffer, 0, 256);
  lora.getVersion(buffer, 256, 1);
  if (Serial) Serial.print(buffer);

  memset(buffer, 0, 256);
  lora.getId(buffer, 256, 1);
  if (Serial) Serial.print(buffer);

  lora.setId(NULL, DevEUI, AppEUI);
  lora.setKey(NULL, NULL, AppKey);
  lora.setDeciveMode(LWOTAA);
  lora.setDataRate(DR0, EU868);
  lora.setAdaptiveDataRate(true);

  lora.setChannel(0, 868.1);
  lora.setChannel(1, 868.3);
  lora.setChannel(2, 868.5);
  lora.setChannel(3, 867.1);
  lora.setChannel(4, 867.3);
  lora.setChannel(5, 867.5);
  lora.setChannel(6, 867.7);
  lora.setChannel(7, 867.9);

  lora.setDutyCycle(false);
  lora.setJoinDutyCycle(false);
  lora.setPower(14);
  lora.setPort(33);

  unsigned int nretries = 0;
  while (!lora.setOTAAJoin(JOIN, 20)) {
    nretries++;
    if (Serial) Serial.println((String)"Join failed, retry: " + nretries);
  }
  Serial.println("Join successful!");
}

// LOOP --------------------------------------------------------------------
unsigned int nloops = 0;
void loop(void) {
  nloops++;
  if (Serial) Serial.println((String)"Loop " + nloops + "...");

  // Read accelerometer
  float ax = myIMU.readFloatAccelX();
  float ay = myIMU.readFloatAccelY();
  float az = myIMU.readFloatAccelZ();
  Serial.print("Accel X: "); Serial.print(ax);
  Serial.print(" Y: "); Serial.print(ay);
  Serial.print(" Z: "); Serial.println(az);

  // Read vibration
  int vibration = digitalRead(PIN_VIBRATION);
  Serial.print("Vibration: "); Serial.println(vibration);

  // Read humidity + temperature
  float humidity = dht.readHumidity();
  float temperature = dht.readTemperature();
  Serial.print("Humidity: "); Serial.print(humidity);
  Serial.print(" Temp: "); Serial.println(temperature);

  // Read GPS (feed for up to 500ms)
  unsigned long start = millis();
  while (millis() - start < 500) {
    while (gpsSerial.available()) {
      gps.encode(gpsSerial.read());
    }
  }
  if (gps.location.isValid()) {
    Serial.print("GPS: ");
    Serial.print(gps.location.lat(), 6);
    Serial.print(", ");
    Serial.println(gps.location.lng(), 6);
  } else {
    Serial.println("GPS: no fix yet");
  }

  // Encode with CayenneLPP
  lpp.reset();
  lpp.addAccelerometer(1, ax, ay, az);          // channel 1
  lpp.addDigitalInput(2, vibration);             // channel 2
  if (!isnan(humidity)) {
    lpp.addRelativeHumidity(3, humidity);        // channel 3
    lpp.addTemperature(4, temperature);          // channel 4
  }
  if (gps.location.isValid()) {
    lpp.addGPS(5, gps.location.lat(),            // channel 5
                  gps.location.lng(),
                  gps.altitude.meters());
  }

  // Send
  bool result = lora.transferPacket(lpp.getBuffer(), lpp.getSize(), 5);

  if (result) {
    short length, rssi;
    char rx[256];
    length = lora.receivePacket(rx, 256, &rssi);
    if (length) {
      Serial.print("Downlink RSSI: "); Serial.println(rssi);
      Serial.print("Length is: "); Serial.println(length);
      Serial.print("RSSI is: "); Serial.println(rssi);
      Serial.print("Data is: ");
      for (unsigned char i = 0; i < length; i++) {
        Serial.print("0x");
        Serial.print(rx[i], HEX);
        Serial.print(" ");
      }
      int rx_data_asInteger = atoi(rx);
      Serial.println();
      Serial.println("Received data: " + String(rx_data_asInteger));
    }
  }

  if (Serial) Serial.println((String)"Loop " + nloops + "...done!\n");

  delay(30000);
}

Source: generated by claude.ai, based on the previous sketch, improved by adding the 3 new sensors.


The new data shows up in the serial monitor and in TTN application live data:


Riding with the bike provided results from all the sensors:


TTN gateway coverage near where we are planning to do our bike ride.


Sampling rate for our sensors, plus bucket and send window lengths. Work in progress, but much better than sending a single value every 30 seconds for each sensor.


The final sketch that was used for the measurements:

// Seeeduino LoRaWAN ------------------------------------------------------------
#define PIN_GROVE_POWER 38



// LoRaWAN -----------------------------------------------------------------------
#include <LoRaWan.h>

// Accelerometer -----------------------------------------------------------------
#include <BMI088.h>
#include <Wire.h>
Bmi088 bmi18_68(Wire, 0x18, 0x68);
Bmi088 bmi18_69(Wire, 0x18, 0x69);
Bmi088 bmi19_68(Wire, 0x19, 0x68);
Bmi088 bmi19_69(Wire, 0x19, 0x69);
Bmi088* activeIMU = &bmi18_69;
byte activeAccelAddress = 0x18;
byte activeGyroAddress = 0x69;

// CayenneLPP --------------------------------------------------------------------
#include <CayenneLPP.h>
// EU868 DR4 allows up to 222 B application payload in the repeater-compatible
// LoRaWAN regional-parameters table. Keep the local buffer below that limit
// but above our normal packet size so future comfort metrics do not overflow it.
CayenneLPP lpp(200);

// Vibration ---------------------------------------------------------------------
#define PIN_VIBRATION A0

// Humidity ----------------------------------------------------------------------
#include <DHT.h>
#define PIN_DHT D4
#define DHTTYPE DHT22
DHT dht(PIN_DHT, DHTTYPE);

// GPS ---------------------------------------------------------------------------
#include <TinyGPS++.h>
#include <SoftwareSerial.h>
SoftwareSerial gpsSerial(D2, D3); // RX, TX
TinyGPSPlus gps;

// Keys --------------------------------------------------------------------------
#define DevEUI "8765182202E81E04"
#define AppEUI "E89CE2FC9061426D"
#define AppKey "F20FCAC3F0DC7A3BA73033A6AA324127"

// Sampling / sending ------------------------------------------------------------
// Conservative DR4/SF8 fallback for stronger link budget than SF7.
// FROST mapping depends on these LPP channels:
// 3 humidity, 4 temperature, 5 GPS location,
// 10 avg speed, 11 max speed, 12 gyro peak,
// 20-21 vertical accel peak buckets, 30-31 vibration hit-rate buckets,
// 40-41 vertical accel RMS buckets.
const unsigned long SEND_INTERVAL_MS = 25000;
const unsigned long IMU_SAMPLE_MS = 10; // 100 Hz
const unsigned long GPS_SAMPLE_MS = 1000; // 1 Hz summaries
const unsigned long ENV_SAMPLE_MS = 1800000; // DHT22 context only; first read happens in setup, then every 30 min
const unsigned long JOIN_RETRY_MS = 60000;
const unsigned long IMU_RETRY_MS = 5000;
const unsigned char UPLINK_TIMEOUT_S = 60;
const byte SETUP_JOIN_ATTEMPTS = 3;
// Small field-test payload: two 12.5 s buckets keep DR4 airtime low while still
// preserving short-window road-quality variation.
const byte N_BUCKETS = 2;
const unsigned long BUCKET_MS = SEND_INTERVAL_MS / N_BUCKETS;
// Complementary/low-pass gravity estimate for vertical vibration extraction.
// A fixed Z axis is too brittle on a bicycle because the frame leans and pitches.
// Instead, we slowly track the local 1 g direction from the accelerometer and
// measure fast acceleration along that direction. Slow tilt/ramp changes become
// part of the gravity estimate; sharp road impacts remain as vertical vibration.
const float GRAVITY_FILTER_ALPHA = 0.98;
const float MIN_GRAVITY_UPDATE_G = 0.6;
const float MAX_GRAVITY_UPDATE_G = 1.4;
const float STANDARD_GRAVITY_MSS = 9.80665;
const float RADIANS_TO_DEGREES = 57.29578;

struct BucketStats {
unsigned int imuSamples;
unsigned int vibrationHits;
float accelPeakG;
float accelSumSqG;
};

BucketStats buckets[N_BUCKETS];
byte currentBucket = 0;

unsigned long windowStartMs = 0;
unsigned long bucketStartMs = 0;
unsigned long lastImuSampleMs = 0;
unsigned long lastGpsSampleMs = 0;
unsigned long lastEnvSampleMs = 0;

float speedSumKmph = 0.0;
float maxSpeedKmph = 0.0;
unsigned int speedSamples = 0;
float gyroPeakDps = 0.0;
float gravityX = 0.0;
float gravityY = 0.0;
float gravityZ = 1.0;
bool gravityInitialized = false;
float latestAx = 0.0;
float latestAy = 0.0;
float latestAz = 0.0;
float latestGx = 0.0;
float latestGy = 0.0;
float latestGz = 0.0;
float latestAccelMagG = 0.0;
float latestVerticalDynamicG = 0.0;
volatile unsigned int pendingVibrationHits = 0;

float latestHumidity = NAN;
float latestTemperature = NAN;
bool includeEnvInNextPacket = false;
bool gpsFixSeen = false;
bool joined = false;
bool imuReady = false;
unsigned long lastJoinAttemptMs = 0;
unsigned long lastUplinkAcceptedMs = 0;
unsigned long lastImuInitAttemptMs = 0;

void countVibrationHit() {
pendingVibrationHits++;
}

bool tryInitImu(unsigned long now) {
lastImuInitAttemptMs = now;
Bmi088* candidates[] = {&bmi18_69, &bmi19_69, &bmi18_68, &bmi19_68};
byte accelAddresses[] = {0x18, 0x19, 0x18, 0x19};
byte gyroAddresses[] = {0x69, 0x69, 0x68, 0x68};

for (byte i = 0; i < 4; i++) {
if (candidates[i]->begin() > 0) {
candidates[i]->setRange(Bmi088::ACCEL_RANGE_24G, Bmi088::GYRO_RANGE_2000DPS);
activeIMU = candidates[i];
activeAccelAddress = accelAddresses[i];
activeGyroAddress = gyroAddresses[i];
imuReady = true;
gravityInitialized = false;
Serial.print("BMI088 IMU OK; accel=0x");
Serial.print(activeAccelAddress, HEX);
Serial.print(" gyro=0x");
Serial.println(activeGyroAddress, HEX);
return true;
}
}

imuReady = false;
Serial.println("BMI088 IMU error at accel 0x18/0x19 and gyro 0x68/0x69; will retry");
return false;
}

void resetBucket(byte index) {
buckets[index].imuSamples = 0;
buckets[index].vibrationHits = 0;
buckets[index].accelPeakG = 0.0;
buckets[index].accelSumSqG = 0.0;
}

void resetWindow(unsigned long now) {
for (byte i = 0; i < N_BUCKETS; i++) {
resetBucket(i);
}

currentBucket = 0;
windowStartMs = now;
bucketStartMs = now;
speedSumKmph = 0.0;
maxSpeedKmph = 0.0;
speedSamples = 0;
gyroPeakDps = 0.0;
}

void feedGps() {
while (gpsSerial.available()) {
gps.encode(gpsSerial.read());
}

if (!gpsFixSeen && gps.location.isValid()) {
gpsFixSeen = true;
Serial.println("GPS first fix acquired; location will be included when valid");
}
}

void sampleImuAndVibration() {
noInterrupts();
unsigned int vibrationHits = pendingVibrationHits;
pendingVibrationHits = 0;
interrupts();
// Count samples that saw at least one SW-420 edge, not raw edges. This keeps
// the reported hit-rate in 0-100% even if the sensor chatters during one
// 10 ms sample interval.
if (vibrationHits > 0) {
buckets[currentBucket].vibrationHits++;
}
buckets[currentBucket].imuSamples++;

if (!imuReady) {
return;
}

activeIMU->readSensor();
float ax = activeIMU->getAccelX_mss() / STANDARD_GRAVITY_MSS;
float ay = activeIMU->getAccelY_mss() / STANDARD_GRAVITY_MSS;
float az = activeIMU->getAccelZ_mss() / STANDARD_GRAVITY_MSS;
float gx = activeIMU->getGyroX_rads() * RADIANS_TO_DEGREES;
float gy = activeIMU->getGyroY_rads() * RADIANS_TO_DEGREES;
float gz = activeIMU->getGyroZ_rads() * RADIANS_TO_DEGREES;
latestAx = ax;
latestAy = ay;
latestAz = az;
latestGx = gx;
latestGy = gy;
latestGz = gz;

float accelMagG = sqrt(ax * ax + ay * ay + az * az);
latestAccelMagG = accelMagG;
if (accelMagG > MIN_GRAVITY_UPDATE_G && accelMagG < MAX_GRAVITY_UPDATE_G) {
float unitX = ax / accelMagG;
float unitY = ay / accelMagG;
float unitZ = az / accelMagG;

if (!gravityInitialized) {
gravityX = unitX;
gravityY = unitY;
gravityZ = unitZ;
gravityInitialized = true;
} else {
gravityX = GRAVITY_FILTER_ALPHA * gravityX + (1.0 - GRAVITY_FILTER_ALPHA) * unitX;
gravityY = GRAVITY_FILTER_ALPHA * gravityY + (1.0 - GRAVITY_FILTER_ALPHA) * unitY;
gravityZ = GRAVITY_FILTER_ALPHA * gravityZ + (1.0 - GRAVITY_FILTER_ALPHA) * unitZ;

float gravityMag = sqrt(gravityX * gravityX + gravityY * gravityY + gravityZ * gravityZ);
if (gravityMag > 0.0) {
gravityX /= gravityMag;
gravityY /= gravityMag;
gravityZ /= gravityMag;
}
}
}

// Vertical vibration proxy in g: project acceleration onto the slowly tracked
// gravity/support direction, then remove the static 1 g component. This is not
// a lab-grade inertial navigation solution, but it is much better than either
// using raw Z or total magnitude on a leaning/pitching bicycle.
float verticalDynamicG = (ax * gravityX + ay * gravityY + az * gravityZ) - 1.0;
latestVerticalDynamicG = verticalDynamicG;
float absVerticalDynamicG = fabs(verticalDynamicG);
if (absVerticalDynamicG > buckets[currentBucket].accelPeakG) {
buckets[currentBucket].accelPeakG = absVerticalDynamicG;
}
buckets[currentBucket].accelSumSqG += verticalDynamicG * verticalDynamicG;

float gyroMagDps = sqrt(gx * gx + gy * gy + gz * gz);
if (gyroMagDps > gyroPeakDps) {
gyroPeakDps = gyroMagDps;
}

}

void sampleGpsSummary() {
if (gps.speed.isValid()) {
float speedKmph = gps.speed.kmph();
speedSumKmph += speedKmph;
if (speedKmph > maxSpeedKmph) {
maxSpeedKmph = speedKmph;
}
speedSamples++;
}
}

void sampleEnvironment(bool forceInclude) {
// DHT22 reads block briefly; sampling once per minute keeps that from dominating the loop.
float humidity = dht.readHumidity();
float temperature = dht.readTemperature();

if (!isnan(humidity) && !isnan(temperature)) {
latestHumidity = humidity;
latestTemperature = temperature;
includeEnvInNextPacket = true;

Serial.print("Environment: humidity=");
Serial.print(latestHumidity);
Serial.print(" temp=");
Serial.println(latestTemperature);
} else if (forceInclude) {
includeEnvInNextPacket = true;
Serial.println("Environment: first DHT read failed; will retry later");
}
}

void advanceBucket(unsigned long now) {
while (now - bucketStartMs >= BUCKET_MS && currentBucket < N_BUCKETS - 1) {
currentBucket++;
bucketStartMs += BUCKET_MS;
}
}

void buildCayennePayload() {
lpp.reset();

// Keep CayenneLPP so the existing TTN decoder/integration still works.
if (gps.location.isValid()) {
lpp.addGPS(5, gps.location.lat(), gps.location.lng(), gps.altitude.meters());
}

if (includeEnvInNextPacket && !isnan(latestHumidity) && !isnan(latestTemperature)) {
lpp.addRelativeHumidity(3, latestHumidity);
lpp.addTemperature(4, latestTemperature);
}

float avgSpeedKmph = speedSamples ? speedSumKmph / speedSamples : 0.0;
// Speed gets both average and max; road impact/vibration uses per-bucket peaks/hit rates.
lpp.addAnalogInput(10, avgSpeedKmph);
lpp.addAnalogInput(11, maxSpeedKmph);
lpp.addAnalogInput(12, gyroPeakDps);

for (byte i = 0; i < N_BUCKETS; i++) {
float vibrationPct = 0.0;
float accelRmsG = 0.0;
if (buckets[i].imuSamples > 0) {
vibrationPct = 100.0 * buckets[i].vibrationHits / buckets[i].imuSamples;
accelRmsG = sqrt(buckets[i].accelSumSqG / buckets[i].imuSamples);
}

lpp.addAnalogInput(20 + i, buckets[i].accelPeakG);
lpp.addAnalogInput(30 + i, vibrationPct);
lpp.addAnalogInput(40 + i, accelRmsG);
}
}

bool tryJoin(unsigned long now) {
lastJoinAttemptMs = now;
Serial.println("Joining LoRaWAN...");
joined = lora.setOTAAJoin(JOIN, 20);
Serial.println(joined ? "Join successful!" : "Join failed; will retry later");
return joined;
}

void sendWindow(unsigned long now) {
if (!joined) {
if (now - lastJoinAttemptMs >= JOIN_RETRY_MS) {
tryJoin(now);
}

if (!joined) {
Serial.println("Skipping uplink until LoRaWAN join succeeds");
resetWindow(millis());
return;
}
}

buildCayennePayload();

unsigned long sendStartMs = millis();
Serial.print("Uptime seconds: ");
Serial.println(sendStartMs / 1000);
if (lastUplinkAcceptedMs > 0) {
Serial.print("Seconds since previous accepted uplink: ");
Serial.println((sendStartMs - lastUplinkAcceptedMs) / 1000);
}
Serial.print("Sending CayenneLPP bytes: ");
Serial.println(lpp.getSize());
Serial.print("GPS valid: ");
Serial.println(gps.location.isValid() ? "yes" : "no");
Serial.print("IMU ready: ");
Serial.println(imuReady ? "yes" : "no");
if (imuReady) {
Serial.print("BMI088 I2C addresses accel/gyro: 0x");
Serial.print(activeAccelAddress, HEX);
Serial.print("/0x");
Serial.println(activeGyroAddress, HEX);
}
Serial.print("IMU accel xyz/mag/verticalDyn: ");
Serial.print(latestAx); Serial.print(", ");
Serial.print(latestAy); Serial.print(", ");
Serial.print(latestAz); Serial.print(" / ");
Serial.print(latestAccelMagG); Serial.print(" / ");
Serial.println(latestVerticalDynamicG);
Serial.print("IMU gyro xyz/peak: ");
Serial.print(latestGx); Serial.print(", ");
Serial.print(latestGy); Serial.print(", ");
Serial.print(latestGz); Serial.print(" / ");
Serial.println(gyroPeakDps);
Serial.print("Bucket samples/hits/peak/rms: ");
for (byte i = 0; i < N_BUCKETS; i++) {
float accelRmsG = buckets[i].imuSamples ? sqrt(buckets[i].accelSumSqG / buckets[i].imuSamples) : 0.0;
Serial.print("[");
Serial.print(buckets[i].imuSamples); Serial.print("/");
Serial.print(buckets[i].vibrationHits); Serial.print("/");
Serial.print(buckets[i].accelPeakG); Serial.print("/");
Serial.print(accelRmsG); Serial.print("]");
}
Serial.println();

// Third argument is a library timeout in seconds; transferPacket is unconfirmed uplink.
// A longer timeout lets the LoRa modem report +MSGHEX: Done after duty-cycle
// waiting or slow SF8 airtime instead of us treating +MSGHEX: Start as failure.
bool result = lora.transferPacket(lpp.getBuffer(), lpp.getSize(), UPLINK_TIMEOUT_S);

if (result) {
lastUplinkAcceptedMs = millis();
Serial.println("Uplink accepted by modem");
includeEnvInNextPacket = false;

short length, rssi;
char rx[256];
length = lora.receivePacket(rx, 256, &rssi);
if (length) {
Serial.print("Downlink RSSI: "); Serial.println(rssi);
Serial.print("Length is: "); Serial.println(length);
Serial.print("Data is: ");
for (short i = 0; i < length; i++) {
Serial.print("0x");
Serial.print((uint8_t)rx[i], HEX);
Serial.print(" ");
}
Serial.println();
}
} else {
Serial.println("Uplink rejected/failed; not retrying this stale window");
}

resetWindow(millis());
}

// SETUP -------------------------------------------------------------------------
void setup(void)
{
// Gives USB serial time to enumerate before the first debug prints.
delay(5000);
Serial.begin(115200);

// Powerup Grove connectors
pinMode(PIN_GROVE_POWER, OUTPUT);
digitalWrite(PIN_GROVE_POWER, 1);

// Init vibration
pinMode(PIN_VIBRATION, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(PIN_VIBRATION), countVibrationHit, FALLING);

// Init humidity
dht.begin();

// Init GPS
gpsSerial.begin(9600);

// Init IMU. If it fails due to startup timing or wiring vibration, loop()
// retries regularly instead of sending fake zero IMU data forever.
tryInitImu(millis());

// Config LoRaWAN
lora.init();

char buffer[256];
memset(buffer, 0, 256);
lora.getVersion(buffer, 256, 1);
if (Serial) Serial.print(buffer);

memset(buffer, 0, 256);
lora.getId(buffer, 256, 1);
if (Serial) Serial.print(buffer);

lora.setId(NULL, DevEUI, AppEUI);
lora.setKey(NULL, NULL, AppKey);
lora.setDeciveMode(LWOTAA);
// Fixed DR4/SF8: conservative fallback for stronger link budget than SF7.
lora.setDataRate(DR4, EU868);
lora.setAdaptiveDataRate(false);

lora.setChannel(0, 868.1);
lora.setChannel(1, 868.3);
lora.setChannel(2, 868.5);
lora.setChannel(3, 867.1);
lora.setChannel(4, 867.3);
lora.setChannel(5, 867.5);
lora.setChannel(6, 867.7);
lora.setChannel(7, 867.9);

// Library duty cycle is a safety net; SEND_INTERVAL_MS keeps aggregation predictable.
lora.setDutyCycle(true);
lora.setJoinDutyCycle(true);
lora.setPower(14);
lora.setPort(33);

for (byte attempt = 0; attempt < SETUP_JOIN_ATTEMPTS && !joined; attempt++) {
tryJoin(millis());
}

unsigned long now = millis();
sampleEnvironment(true);
lastImuSampleMs = now;
lastGpsSampleMs = now;
lastEnvSampleMs = now;
resetWindow(now);
}

// LOOP --------------------------------------------------------------------
void loop(void) {
unsigned long now = millis();

feedGps();

if (!imuReady && now - lastImuInitAttemptMs >= IMU_RETRY_MS) {
tryInitImu(now);
}

if (now - lastImuSampleMs >= IMU_SAMPLE_MS) {
lastImuSampleMs = now;
sampleImuAndVibration();
}

if (now - lastGpsSampleMs >= GPS_SAMPLE_MS) {
lastGpsSampleMs = now;
sampleGpsSummary();
}

if (now - lastEnvSampleMs >= ENV_SAMPLE_MS) {
lastEnvSampleMs = now;
sampleEnvironment(false);
}

advanceBucket(now);

if (now - windowStartMs >= SEND_INTERVAL_MS) {
sendWindow(now);
}
}



Our grafana dashboards for one of the rides (when we were also experimenting with different data rates, as well as smaller and bigger payloads - you can see the previous payloads used 10 buckets, not 2 as in the end):

  • No labels