← All sub-teams
Software

Software

something geeky

Data Pipeline

The SOLARA pipeline moves environmental and rover telemetry data from the rover to a live dashboard for monitoring and analysis. Sensors connected to the Raspberry Pi 5 and Pi Pico W collect data such as temperature, humidity, soil conditions, and GPS location while ROS 2 manages autonomous navigation and sensor coordination.

The rover publishes this data through MQTT using the Mosquitto broker. On the Lenovo SE350 Edge Server, Docker Compose manages backend services including FastAPI and Azure SQL Edge. FastAPI ingests the MQTT data, stores it in the database, and runs machine learning models like K-Means clustering and Random Forest regression to generate environmental insights and recommendations.

The processed data is then exposed through REST API endpoints and visualized on dashboards such as ArcGIS, allowing users to monitor rover activity, environmental conditions, and analytics in real time.

Step 1

Training the ML Model

solara/ml_pipeline.py
import pandas as pd
from sklearn.preprocessing import MinMaxScaler
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score
from sklearn.cluster import KMeans
from sklearn.ensemble import IsolationForest
import joblib

#loads the csv
df = pd.read_csv("mock_rover_sustainability_data.csv", parse_dates=["timestamp"])

#checks if data is corrupted
df.info()
df.describe()

#separates columns
non_numeric_cols = ["timestamp"]
numeric_cols = df.select_dtypes(include="number").columns



#removes extreme outliers
def remove_outliers_iqr(df, columns, k=1.5):
    filtered_df = df.copy()
    for col in columns:
        Q1 = filtered_df[col].quantile(0.25)
        Q3 = filtered_df[col].quantile(0.75)
        IQR = Q3 - Q1

        lower = Q1 - k * IQR
        upper = Q3 + k * IQR

        filtered_df = filtered_df[
            (filtered_df[col] >= lower) &
            (filtered_df[col] <= upper)
        ]
    return filtered_df

#apply removing extreme outliers to numeric features only (not time stamps)
df_clean = remove_outliers_iqr(df, numeric_cols)

print(f"Rows before: {len(df)}, after: {len(df_clean)}")



#normalzies features using minmax scaling
#(making every value between 0 and 1)
scaler = MinMaxScaler()

df_scaled = df_clean.copy()
df_scaled[numeric_cols] = scaler.fit_transform(df_clean[numeric_cols])

#implementing isolationforesting

iso = IsolationForest(
    n_estimators=200,
    contamination=0.05,
    random_state=42
)

df_scaled["anomaly_score"] = iso.fit_predict(
    df_scaled[numeric_cols]
)

# Convert {-1, 1} → {1 = anomaly, 0 = normal}
df_scaled["anomaly_score"] = (df_scaled["anomaly_score"] == -1).astype(int)


#implementing kmeans

kmeans = KMeans(
    n_clusters=2,
    random_state=42,
    n_init=10
)

df_scaled["cluster_label"] = kmeans.fit_predict(
    df_scaled[numeric_cols]
)

#tests if scaling worked (all cols are between 0 and 1)
assert np.all(df_scaled[numeric_cols].min() >= -1e-9)
assert np.all(df_scaled[numeric_cols].max() <= 1 + 1e-9)

print("Scaling Test Passed!!")


#health rules (subject to change)
def label_health(row):
    if row["surface_temp_max_c"] > 0.8:
        return "Heat stress"
    elif row["soil_ph"] < 0.4 or row["soil_ph"] > 0.7:
        return "pH issue"
    else:
        return "Healthy"

df_scaled["health_status"] = df_scaled.apply(label_health, axis=1)

#adding extra "noise" to take into account imperfect sensors
df_scaled["surface_temp_max_c"] += np.random.normal(0, 0.05, len(df_scaled))



#begin random foresting
target_col = "health_status"


#define x -> features and y -> columns
X = df_scaled[
    list(numeric_cols) +
    ["cluster_label", "anomaly_score"]
]

y = df_scaled["health_status"]

#Train test split
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=42,
    stratify=y
)

#training the random forest
rf = RandomForestClassifier(
    n_estimators=200, #num of trees
    max_depth=None,   #trees can fully grow, won't get cut off
    min_samples_split=2,
    random_state=42,
    n_jobs=-1       #uses all CPU cores
)



rf.fit(X_train, y_train)

#evaluation
y_pred = rf.predict(X_test)

#how accurate the predictions were
print("Accuracy:", accuracy_score(y_test, y_pred))

#precision = true pos/(true pos+ false pos) (accuracy of positive predictions)
#recall = true pos/(true pos + false neg) (ability to find all positives)
#f1 score = mean of precision and recall
#support -> actual numbe of real occurances (true positives, true negatives)

print("\nClassification Report: \n", classification_report(y_test, y_pred))

#confusion matrices show the false postives(bottom left), false negatives(top right),
# true positives(top left), true negatives(bottom right)
print("\n Confusion Matrix: \n ", confusion_matrix(y_test, y_pred))



#pickles -> convert python objects to bytes so we can load it later
#trains model saves it to a pickle so we can load it later for inference
#Joblib is a library built on top of pickle where it actually compresses it and loads it
joblib.dump(rf, "rf_model.pkl")
joblib.dump(scaler, "scaler.pkl")
joblib.dump(kmeans, "kmeans.pkl")
joblib.dump(iso, "iso.pkl")
joblib.dump(list(numeric_cols), "numeric_cols.pkl")

We first preprocess the data. In other words, we clean it!

We use 3 different algorithms for this model:

Isolation foresting: identifies data points that are different from the rest and flags them. This helps us identify sensor malfunctions, as well as plant stress. This adds an anomaly score that we will feed into our model later.

K-means clustering: groups areas together based on similar parameters. In our case, it will group areas of soil with similar conditions. This will add a cluster label that we will feed into our model later.

Random foresting: a bunch of decision trees that the model takes the average of each of these trees to classify the soil. We use soil features as well as the anomaly score and cluster label from earlier as input features.

After training our model, we pickle the data using Python's Joblib library. This allows us to save our trained model and reuse it at any time without needing to retrain it.

Tech Stack
Library Purpose
pandas Load and manipulate CSV data
NumPy Numerical operations and array handling
Matplotlib Data visualization
scikit-learn — MinMaxScaler Normalize all features to a [0, 1] range
scikit-learn — IsolationForest Detect anomalies in sensor readings
scikit-learn — KMeans Cluster soil zones by similar conditions
scikit-learn — RandomForestClassifier Classify soil health status
Joblib Serialize and reload trained models without retraining

Step 2

FastAPI Backend

api/esri.py
from fastapi import APIRouter, HTTPException
from api.routers.sensors import latest_sensor_data
from api.routers.predictions import latest_predictions

router = APIRouter()

def format_geojson(sensor: dict, predictions: dict) -> dict:
    if not sensor:
        return {}
    return {
        "type": "FeatureCollection",
        "features": [
            {
                "type": "Feature",
                "geometry": {
                    "type": "Point",
                    "coordinates": [
                        sensor.get("longitude"),
                        sensor.get("latitude")
                    ]
                },
                "properties": {
                    "timestamp": sensor.get("timestamp"),
                    "ambient_temp_c": sensor.get("ambient_temp_c"),
                    "humidity_pct": sensor.get("humidity_pct"),
                    "soil_moisture_pct": sensor.get("soil_moisture_pct"),
                    "soil_ph": sensor.get("soil_ph"),
                    "battery_pct": sensor.get("battery_pct"),
                    "kmeans_cluster": predictions.get("kmeans", {}).get("cluster"),
                    "is_outlier": predictions.get("isolation_forest", {}).get("is_outlier"),
                    "rf_prediction": predictions.get("random_forest", {}).get("prediction"),
                }
            }
        ]
    }

@router.get("/esri/geojson")
def get_geojson():
    if not latest_sensor_data:
        raise HTTPException(status_code=404, detail="No data available yet")
    return format_geojson(latest_sensor_data, latest_predictions)
main.py
import sys
sys.path.append("/app")

from fastapi import FastAPI
from contextlib import asynccontextmanager
from messaging.mqtt_client import start_mqtt, stop_mqtt
from api.routers import sensors, predictions
from api import esri

@asynccontextmanager
async def lifespan(app: FastAPI):
    start_mqtt()
    yield
    stop_mqtt()

app = FastAPI(title="Solara Edge Server", lifespan=lifespan)

app.include_router(sensors.router)
app.include_router(predictions.router)
app.include_router(esri.router)

@app.get("/")
def root():
    return {"service": "solara-edge-server", "status": "running"}

@app.get("/health")
def health():
    return {"status": "ok"}

The FastAPI backend is the central hub of the SOLARA pipeline, sitting between the rover and the dashboard. When the Mosquitto broker receives a sensor payload over MQTT, FastAPI ingests it, validates the incoming JSON, and writes the raw reading to the Azure SQL Edge database.

Once the data is stored, FastAPI loads the pre-trained models from their pickled files and runs inference — generating a soil health prediction for each new reading. That prediction is then written to the predictions table and linked back to its original sensor entry.

The format_geojson function is where everything converges: it pulls the latest sensor reading and ML predictions together and packages them as a GeoJSON FeatureCollection. This is the format ArcGIS expects, embedding rover coordinates as geometry and all sensor and model outputs as properties on the same feature.

main.py wires the whole app together — it starts and stops the MQTT client via FastAPI's lifespan hook, and registers the sensors, predictions, and ESRI routers so every endpoint is available when the server boots.

Tech Stack
Tool Purpose
FastAPI Web framework for building REST API endpoints
MQTT / Mosquitto Message broker that receives rover telemetry
GeoJSON Standard format for encoding geographic data consumed by ArcGIS
Lifespan hooks Manages MQTT client startup and shutdown alongside the server

Step 3

MQTT & Mosquitto Broker

solara-rover/env_data/collector.py

import time, yaml, json, rclpy
from sensors import environment, light, thermal
import paho.mqtt.client as mqtt
from sensor_msgs.msg import NavSatFix, Imu
from rclpy.node import Node
from datetime import datetime

# LOAD CONFIG
with open("config.yaml", "r") as f:
    config = yaml.safe_load(f)
broker   = config["broker"]
topics   = config["topics"]
INTERVAL = config["collector"]["poll_interval"]
QoS = 1

# MQTT CALLBACKS
def on_connect(client, userdata, flags, reason_code, properties):
    print(f"[MQTT] CONNACK rc={reason_code}")

def on_publish(client, userdata, mid):
    print(f"[MQTT] PUBACK received for PacketId={mid}")

def on_disconnect(client, userdata, rc):
    if rc != 0:
        print(f"[MQTT] Unexpected disconnect (rc={rc}). Will auto-reconnect.")

# COLLECTOR NODE
class CollectorNode(Node):
    def __init__(self, mqtt_client):
        super().__init__('collector')
        self.mqtt_client = mqtt_client

        # Cache for latest GPS fix - None until first message arrives
        self.latest_gps = None

        # Cache for latest IMU data - None until first message arrives
        # self.latest_imu = None

        # Subscribe to GPS topic published by gps_driver.py
        self.gps_sub = self.create_subscription(
            NavSatFix,
            "/gps/fix",
            self.gps_callback,
            10
        )

        # Timer replaces time.sleep() - fires collect_and_publish() every INTERVAL seconds
        self.timer = self.create_timer(INTERVAL, self.collect_and_publish)

        self.get_logger().info(f"Collector started, publishing every {INTERVAL}s")

    def gps_callback(self, msg: NavSatFix):
        self.latest_gps = {
            "latitude":  msg.latitude,
            "longitude": msg.longitude,
            "altitude":  msg.altitude,
        }

    def collect_and_publish(self):
        timestamp = datetime.now(pytz.timezone('America/Los_Angeles')).strftime("%Y-%m-%dT%H:%M:%S PST")
        env_data     = environment.get_data()
        light_data   = light.get_data()
        thermal_data = thermal.get_data()

        gps_data = self.latest_gps if self.latest_gps is not None else {
            "latitude":  None,
            "longitude": None,
            "altitude":  None,
        }

        # ML topic: environment + light
        self.mqtt_client.publish(
            topics["ML"],
            json.dumps({
                "timestamp":   timestamp,
                "environment": env_data,
                "light":       light_data,
            }),
            qos=QoS
        )

        # ESRI topic: thermal + gps + imu
        self.mqtt_client.publish(
            topics["ESRI"],
            json.dumps({
                "timestamp": timestamp,
                "thermal":   thermal_data,
                "gps":       gps_data,
            }),
            qos=QoS
        )

        self.get_logger().info(f"Published at {timestamp}. GPS: {gps_data}")


# MAIN
def main():
    client = mqtt.Client(
        callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
        client_id=broker["client_id"],
        clean_session=broker["clean_session"]
    )
    client.on_connect    = on_connect
    client.on_publish    = on_publish
    client.on_disconnect = on_disconnect
    
    client.will_set(topics["status"], json.dumps({"status": "offline"}), qos=QoS, retain=True)
    client.connect(host=broker["host"], port=broker["port"], keepalive=broker["keepalive"])
    client.loop_start()
    client.publish(topics["status"], json.dumps({"status": "online"}), qos=QoS, retain=True)
    
    rclpy.init()
    node = CollectorNode(mqtt_client=client)
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        print("[COLLECTOR] Shutting down.")
    finally:
        node.destroy_node()
        rclpy.shutdown()
        client.loop_stop()
        client.disconnect()


if __name__ == "__main__":
    main()
            
solara-server/mqtt5/subscriber.py
import paho.mqtt.client as mqtt
import json, yaml, os
from datetime import datetime

with open("config.yaml", "r") as f:
    config = yaml.safe_load(f)

broker   = config["broker"]
topics   = config["topics"]
QoS      = config["subscriber"]["qos"]
DATA_DIR = config["subscriber"]["data_dir"]
os.makedirs(DATA_DIR, exist_ok=True)

def on_connect(client, userdata, flags, reason_code, properties):
    print(f"[SUBSCRIBER] Connected rc={reason_code}")
    client.subscribe(topics["ML"],     qos=QoS)
    client.subscribe(topics["ESRI"],   qos=QoS)
    client.subscribe(topics["status"], qos=QoS)

    def on_message(client, userdata, msg):
        try:
            payload = json.loads(msg.payload.decode())
        except json.JSONDecodeError as e:
            print(f"[SUBSCRIBER] Bad JSON on {msg.topic}: {e}")
            return
        timestamp  = datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%SZ")
        topic_slug = msg.topic.replace("/", "_")
        filepath   = os.path.join(DATA_DIR, f"{topic_slug}_{timestamp}.json")
        with open(filepath, "w") as f:
            json.dump(payload, f, indent=2)
        print(f"[SUBSCRIBER] Saved --> {filepath}")

    client = mqtt.Client(
        callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
        client_id=broker["client_id"],
        clean_session=broker["clean_session"]
    )
    client.on_connect = on_connect
    client.on_message = on_message

    client.connect(broker["host"], broker["port"], broker["keepalive"])
    client.loop_forever()

MQTT (Message Queuing Telemetry Transport) is the lightweight publish-subscribe protocol the rover uses to stream live sensor data to the edge server. The Mosquitto broker sits between the two, running as its own Docker container on the Lenovo SE350, and is responsible for routing every message from publisher to subscriber.

On the rover, collector.py runs as a ROS 2 node on a 5-second timer. Each cycle it reads from three sensor modules — environment, light, and thermal — and pulls the latest GPS coordinates from the ROS 2 /gps/fix topic. Rather than sending everything in one payload, the data is split across two topics: sensors/ML carries environment and light readings for the machine learning pipeline, while sensors/ESRI carries thermal and GPS data for ArcGIS mapping. A retained sensors/status message also lets the server know whether the rover is online or offline at any given moment.

On the server, subscriber.py connects to the same Mosquitto broker and listens on all three topics simultaneously. When a message arrives, it decodes the JSON payload and writes it to a timestamped file, where FastAPI picks it up, runs inference using the pre-trained models, and stores the results in Azure SQL Edge for the dashboard to query.

Tech Stack
Tool Purpose
paho-mqtt Python MQTT client used by both collector and subscriber
Mosquitto Broker Routes published messages from the rover to the server subscriber
ROS 2 Manages the collector as a node and provides the GPS fix via /gps/fix
Docker Runs Mosquitto as an isolated container on the Lenovo SE350
config.yaml Centralizes broker IP, topic names, QoS, and poll interval for both sides

Step 4

The Dashboard

schema.sql
-- 1. Create the Raw Sensor Data table
          CREATE TABLE raw_sensor_data (
              id INT PRIMARY KEY IDENTITY(1,1),
              sensor_id VARCHAR(50) NOT NULL,
              json_payload NVARCHAR(MAX) NOT NULL,
              timestamp DATETIME2 DEFAULT SYSUTCDATETIME()
          );
          
          -- 2. Create the Predictions table
          CREATE TABLE predictions (
              prediction_id INT PRIMARY KEY IDENTITY(1,1),
              raw_data_id INT NOT NULL,
              prediction_value FLOAT NOT NULL,
              model_version VARCHAR(20),
              created_at DATETIME2 DEFAULT SYSUTCDATETIME(),
          
              CONSTRAINT FK_RawData FOREIGN KEY (raw_data_id)
              REFERENCES raw_sensor_data(id) ON DELETE CASCADE
          );
          
          -- 3. Define Indexes
          CREATE INDEX IX_SensorData_Timestamp ON raw_sensor_data(timestamp);
          CREATE INDEX IX_Predictions_RawDataID ON predictions(raw_data_id);
          
          GO
          -- Data retention: cleanup of data older than 30 days
          CREATE PROCEDURE sp_CleanupOldData
          AS
          BEGIN
              DELETE FROM raw_sensor_data
              WHERE timestamp < DATEADD(day, -30, GETDATE());
          END;
          GO
test_scripts.sql
-- 1. Simulate the Aggregator Node inserting data
          INSERT INTO raw_sensor_data (sensor_id, json_payload)
          VALUES ('ROVER_01', '{
              "ambient_temp_c": 23.69,
              "humidity_pct": 50.78,
              "soil_moisture_pct": 39.90,
              "battery_pct": 99.73,
              "location": {"lat": 33.97, "long": -117.32}
          }');
          
          -- 2. Simulate the ML Module creating a prediction
          INSERT INTO predictions (raw_data_id, prediction_value, model_version)
          VALUES (SCOPE_IDENTITY(), 0.95, 'v1.0-beta');
          
          -- 3. Join the tables to see the full result
          SELECT
              r.timestamp,
              r.sensor_id,
              r.json_payload,
              p.prediction_value
          FROM raw_sensor_data r
          JOIN predictions p ON r.id = p.raw_data_id;

The dashboard is the layer of the pipeline where raw sensor telemetry and ML predictions come together into something human-readable.

The database runs inside a local Azure SQL Edge Docker container, a lightweight version of Microsoft SQL Server built for edge and IoT devices. It is initiated automatically with the rest of the system via docker-compose.

The schema is made up of two tables. raw_sensor_data stores the incoming JSON payloads from the rover, including ambient temperature, humidity, battery percentage, and GPS coordinates. predictions stores the ML model's output for each reading, linked back to the original data via a foreign key. So if a raw reading is deleted, its prediction is automatically removed too.

To power the dashboard, we join both tables so each sensor reading appears alongside its health prediction score. This lets us display not just what the rover recorded, but what the model thinks it means for soil health.

We also built in a data retention policy, where a stored procedure automatically purges records older than 30 days, keeping the dashboard responsive and the database from growing indefinitely.

What the Dashboard Shows
Panel Data Source
Live sensor readings raw_sensor_data.json_payload
Soil health prediction predictions.prediction_value>
Rover location lat / long from JSON payload
Timestamp of last reading raw_sensor_data.timestamp>
Tech Stack
Tool Purpose
Azure SQL Edge Lightweight containerized database for IoT/edge devices
Stored Procedures Automated 30-day data cleanup
Docker Initializes the database container via docker-compose
FastAPI API layer that serves dashboard data from the database
App Controller

The SOLARA mobile app is built in Flutter and serves as the operator's interface for the rover. It connects to the rover over Bluetooth Low Energy (BLE), allowing a user to power the rover on or off, switch between autonomous and manual modes, set a leash distance, and monitor live status notifications — all from their phone.

The app is structured around a central BluetoothManager service that handles the full BLE lifecycle: scanning for devices, connecting, discovering GATT services and characteristics, sending commands, and streaming status updates back to the UI. The rover exposes a single BLE service with two characteristics — one writable for commands and one notifiable for status — whose UUIDs are defined in ble_constants.dart. A mock mode flag lets the team develop and test the UI without a physical rover present.

main.dart wires together all the UI widgets into a single scrollable home screen: the on/off toggle, Bluetooth connect button, leash distance slider, and quick action controls. Tapping the Bluetooth button navigates to a dedicated scan screen where the app searches for devices advertising the rover's service UUID, lists any matches, and manages the connection state with live feedback. Once connected, incoming status payloads stream directly into the UI via Dart's StreamController, so the displayed state always reflects what the rover is actually reporting.

lib/bluetooth_service.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';

import 'ble_constants.dart';

enum BleUiConnectionState { disconnected, connecting, connected }

class DiscoveredBleDevice {
  const DiscoveredBleDevice({
    required this.id,
    required this.name,
    this.device,
    this.isMock = false,
  });
  final String id;
  final String name;
  final BluetoothDevice? device;
  final bool isMock;
}

class BluetoothManager {
  BluetoothDevice? _connectedDevice;
  BluetoothCharacteristic? _commandCharacteristic;
  BluetoothCharacteristic? _statusCharacteristic;
  bool _isMockConnected = false;
  List<int> _mockStatus = utf8.encode('MOCK_DISCONNECTED');

  StreamSubscription<List<ScanResult>>? _scanSubscription;
  StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
  StreamSubscription<List<int>>? _statusNotificationSubscription;

  final List<DiscoveredBleDevice> _discoveredDevices = [];
  final StreamController<List<DiscoveredBleDevice>> _devicesController =
      StreamController<List<DiscoveredBleDevice>>.broadcast();
  final StreamController<BleUiConnectionState> _connectionController =
      StreamController<BleUiConnectionState>.broadcast();
  final StreamController<List<int>> _statusController =
      StreamController<List<int>>.broadcast();

  Stream<List<DiscoveredBleDevice>> get devicesStream => _devicesController.stream;
  Stream<BleUiConnectionState> get connectionStream => _connectionController.stream;
  Stream<List<int>> get statusStream => _statusController.stream;

  DiscoveredBleDevice? get connectedDevice {
    if (_isMockConnected) {
      return const DiscoveredBleDevice(id: 'mock-rover-id', name: kMockDeviceName, isMock: true);
    }
    final device = _connectedDevice;
    if (device == null) return null;
    return DiscoveredBleDevice(
      id: device.remoteId.toString(),
      name: device.platformName.isNotEmpty ? device.platformName : 'Unknown Device',
      device: device,
    );
  }

  Future<bool> isBluetoothSupported() async => FlutterBluePlus.isSupported;
  Future<BluetoothAdapterState> getBluetoothState() async => FlutterBluePlus.adapterState.first;
  Future<void> turnOn() async => FlutterBluePlus.turnOn();

  Future<void> startScan({
    Duration timeout = const Duration(seconds: 6),
    String? nameFilter,
    String? serviceUuidFilter,
  }) async {
    await stopScan();
    _discoveredDevices.clear();
    _emitDiscoveredDevices();
    await ensureRuntimePermissions();

    if (kUseMockBLE) {
      await Future<void>.delayed(const Duration(milliseconds: 450));
      _discoveredDevices.add(const DiscoveredBleDevice(
        id: 'mock-rover-id', name: kMockDeviceName, isMock: true,
      ));
      _emitDiscoveredDevices();
      return;
    }

    final Guid targetService = Guid(serviceUuidFilter ?? kRoverServiceUUID);
    final String normalizedName = (nameFilter ?? 'rover').toLowerCase();

    _scanSubscription = FlutterBluePlus.scanResults.listen((results) {
      for (final result in results) {
        final device = result.device;
        final bool matchesName = device.platformName.toLowerCase().contains(normalizedName);
        final bool matchesService = result.advertisementData.serviceUuids
            .any((s) => s.str.toLowerCase() == targetService.str.toLowerCase());
        if (!matchesName && !matchesService) continue;
        final String id = device.remoteId.toString();
        if (_discoveredDevices.any((d) => d.id == id)) continue;
        _discoveredDevices.add(DiscoveredBleDevice(
          id: id,
          name: device.platformName.isNotEmpty ? device.platformName : 'Unknown Device',
          device: device,
        ));
      }
      _emitDiscoveredDevices();
    });

    await FlutterBluePlus.startScan(timeout: timeout, withServices: [targetService]);
  }

  Future<void> stopScan() async {
    await _scanSubscription?.cancel();
    _scanSubscription = null;
    await FlutterBluePlus.stopScan();
  }

  Future<void> connectToDevice(DiscoveredBleDevice target, {int retries = 2}) async {
    _connectionController.add(BleUiConnectionState.connecting);
    await stopScan();

    if (target.isMock) {
      await Future<void>.delayed(const Duration(milliseconds: 700));
      _isMockConnected = true;
      _mockStatus = utf8.encode('MOCK_CONNECTED');
      _connectionController.add(BleUiConnectionState.connected);
      _statusController.add(_mockStatus);
      return;
    }

    final device = target.device;
    if (device == null) {
      _connectionController.add(BleUiConnectionState.disconnected);
      throw Exception('Selected device is unavailable.');
    }
    await ensureRuntimePermissions();

    Object? lastError;
    for (int attempt = 1; attempt <= retries + 1; attempt++) {
      try {
        await device.connect(timeout: const Duration(seconds: 12), autoConnect: false);
        _connectedDevice = device;
        _connectionController.add(BleUiConnectionState.connected);
        _connectionStateSubscription = device.connectionState.listen((state) {
          if (state == BluetoothConnectionState.disconnected) {
            _connectedDevice = null;
            _commandCharacteristic = null;
            _statusCharacteristic = null;
            _connectionController.add(BleUiConnectionState.disconnected);
          }
        });
        await discoverAndBindCharacteristics();
        return;
      } catch (e) {
        lastError = e;
        if (attempt <= retries) await Future<void>.delayed(const Duration(seconds: 1));
      }
    }
    _connectionController.add(BleUiConnectionState.disconnected);
    throw Exception('Failed to connect after retries: $lastError');
  }

  Future<void> discoverAndBindCharacteristics() async {
    final device = _connectedDevice;
    if (device == null) throw Exception('No device connected');
    final services = await device.discoverServices();
    final roverService = services.cast<BluetoothService?>().firstWhere(
      (s) => s?.uuid == Guid(kRoverServiceUUID), orElse: () => null,
    );
    if (roverService == null) throw Exception('Rover service not found');
    _commandCharacteristic = roverService.characteristics.cast<BluetoothCharacteristic?>()
        .firstWhere((c) => c?.uuid == Guid(kCommandCharacteristicUUID), orElse: () => null);
    _statusCharacteristic = roverService.characteristics.cast<BluetoothCharacteristic?>()
        .firstWhere((c) => c?.uuid == Guid(kStatusCharacteristicUUID), orElse: () => null);
    if (_commandCharacteristic == null) throw Exception('Command characteristic not found');
    if (_statusCharacteristic == null) throw Exception('Status characteristic not found');
  }

  Future<void> sendCommand(List<int> value) async {
    if (_isMockConnected) {
      _mockStatus = utf8.encode('MOCK_CMD:${value.join(",")}');
      _statusController.add(_mockStatus);
      return;
    }
    final command = _commandCharacteristic;
    if (command == null) throw Exception('Command characteristic unavailable.');
    await command.write(value, withoutResponse: command.properties.writeWithoutResponse);
  }

  Future<List<int>> readStatus() async {
    if (_isMockConnected) return _mockStatus;
    final status = _statusCharacteristic;
    if (status == null) throw Exception('Status characteristic unavailable.');
    return status.read();
  }

  Future<void> subscribeToStatus() async {
    if (_isMockConnected) {
      await _statusNotificationSubscription?.cancel();
      _statusNotificationSubscription =
          Stream<List<int>>.periodic(const Duration(seconds: 2), (i) => utf8.encode('MOCK_NOTIFY_$i'))
              .listen(_statusController.add);
      return;
    }
    final status = _statusCharacteristic;
    if (status == null) throw Exception('Status characteristic unavailable.');
    await status.setNotifyValue(true);
    await _statusNotificationSubscription?.cancel();
    _statusNotificationSubscription = status.lastValueStream.listen(_statusController.add);
  }

  Future<void> disconnect() async {
    await _statusNotificationSubscription?.cancel();
    _statusNotificationSubscription = null;
    if (_isMockConnected) {
      _isMockConnected = false;
      _mockStatus = utf8.encode('MOCK_DISCONNECTED');
      _connectionController.add(BleUiConnectionState.disconnected);
      return;
    }
    if (_connectedDevice != null) {
      await _connectedDevice!.disconnect();
      _connectedDevice = null;
    }
    _commandCharacteristic = null;
    _statusCharacteristic = null;
    _connectionController.add(BleUiConnectionState.disconnected);
  }

  void _emitDiscoveredDevices() =>
      _devicesController.add(List<DiscoveredBleDevice>.unmodifiable(_discoveredDevices));

  Future<void> ensureRuntimePermissions() async {
    if (!Platform.isAndroid && !Platform.isIOS) return;
    final permissions = [Permission.bluetoothScan, Permission.bluetoothConnect, Permission.locationWhenInUse];
    if (Platform.isIOS) permissions.add(Permission.bluetooth);
    for (final p in permissions) {
      if (!(await p.request()).isGranted) throw Exception('Missing permission: $p');
    }
  }

  Future<void> dispose() async {
    await stopScan();
    await disconnect();
    await _connectionStateSubscription?.cancel();
    await _devicesController.close();
    await _connectionController.close();
    await _statusController.close();
  }
}
lib/main.dart
import 'package:flutter/material.dart';
import 'bluetooth_screen.dart';
import 'on_off.dart';
import 'bluetooth.dart';
import 'control_buttons.dart';

void main() => runApp(const SolaraApp());

class SolaraApp extends StatelessWidget {
  const SolaraApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'SOLARA',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          SingleChildScrollView(
            child: Padding(
              padding: const EdgeInsets.all(20.0),
              child: Column(
                children: [
                  const SizedBox(height: 40),
                  Image.asset('assets/logos/solara_logo.png', height: 150),
                  const SizedBox(height: 30),
                  const OnOffToggle(),
                  const SizedBox(height: 30),
                  const BluetoothButtonWithNavigation(),
                  const SizedBox(height: 30),
                  const MyToggleDemo(),
                  const SizedBox(height: 30),
                  const ControlButtons(),
                  const SizedBox(height: 20),
                ],
              ),
            ),
          ),
          Positioned(
            top: 40, right: 20,
            child: Row(
              children: [
                Image.asset('assets/logos/gcap_logo.png', height: 40),
                const SizedBox(width: 15),
                Image.asset('assets/logos/swe_logo.png', height: 40),
                const SizedBox(width: 15),
                Image.asset('assets/logos/tt_logo.png', height: 40),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

The app is built in Flutter, which lets it run on both iOS and Android from a single codebase. The entry point is main.dart, which sets up the app theme and renders the home screen — a scrollable layout containing the power toggle, Bluetooth connect button, leash distance slider, and quick action controls.

BLE logic lives entirely inside bluetooth_service.dart. The BluetoothManager class owns the full connection lifecycle: it scans for devices advertising the rover's GATT service UUID, connects with automatic retries, discovers the command and status characteristics, and exposes three reactive streams — one for discovered devices, one for connection state, and one for incoming status payloads. The UI subscribes to these streams and rebuilds automatically as data arrives, so the displayed state always reflects what the rover is actually reporting. A kUseMockBLE flag in ble_constants.dart lets the team simulate the full BLE flow without a physical rover, which was critical during development while hardware was still being assembled.

On the rover side, the BLE server runs as a ROS 2 node and is launched alongside the rest of the rover's software stack via the main ROS 2 launch file. This means BLE comes up automatically when the rover boots — no manual startup needed — and it shares the same ROS 2 context as the navigation, sensor, and MQTT nodes, so it can subscribe to rover state topics and relay them directly to the phone as status characteristic updates.

The rover exposes two GATT characteristics under a single service: a writable characteristic for sending commands such as PING or mode changes, and a notifiable characteristic that pushes live status updates back to the phone. Once connected, the app subscribes to status notifications so updates stream in continuously rather than requiring the user to poll manually.

Tech Stack
Tool Purpose
Flutter / Dart Cross-platform mobile framework for iOS and Android
flutter_blue_plus BLE scanning, connecting, and GATT characteristic read/write/notify
permission_handler Requests Bluetooth and location permissions at runtime
StreamController Pushes device list, connection state, and status updates reactively to the UI
Mock BLE mode Simulates rover presence for UI development without hardware
Building an AI-Powered Garden Rover: Inside Geek Team’s Machine Learning Pipeline

For many students, machine learning sounds intimidating — something reserved for graduate researchers or experienced engineers. But for the members of Geek Team, it became something much more practical: a collaborative process built around experimentation, debugging, and learning by doing.

This year, Geek Team worked on an ambitious environmental robotics project: developing an intelligent rover capable of monitoring garden health through environmental sensors and machine learning predictions. The project combined robotics, software engineering, backend infrastructure, data science, dashboards, and hardware integration into one connected system designed to analyze soil and environmental conditions in real time.

Speaking with team members Ava Williams, Pramika Bhandari, Tanisha, Ananya, and Sanvi, one theme came up repeatedly: almost nobody started out knowing how to do all of this. Most of them learned the technologies while actively building the project.

For Ava Williams, a third-year Computer Science major, the project was an opportunity to explore a growing interest in machine learning.

“One of my biggest interests is the intersection of cybersecurity and machine learning and secure machine learning models,” Ava explained. “I think it’s cool that we can predict things.”

Although she had experimented with machine learning before joining Geek Team, much of her experience came from self-teaching through online resources, AI tools, and independent projects.

“I made my first ML model over the summer predicting Premier League outcomes,” she said. “That was my first experience getting my feet wet into machine learning.”

Pramika Bhandari, a third-year Data Science major, was drawn to a similar idea: using data to improve real-world systems.

“Being able to make data-driven recommendations on how to improve systems was really interesting to me,” she explained. “We can use models to predict outcomes that could improve systems.”

Together, the machine learning team worked on building a pipeline capable of analyzing environmental sensor data from the rover and generating predictions about garden health. Before development even began, the students consulted with a UC Riverside professor to verify that their chosen algorithms would work effectively for the type and size of data they expected to receive.

The pipeline itself involved several stages: preprocessing and cleaning sensor data, detecting anomalies, clustering environmental conditions, classifying garden health, and building inference scripts for deployment.

To accomplish this, the team used several machine learning approaches, including K-Means Clustering, Isolation Foresting, and Random Forest Classification.

“K-Means helped us identify different zones in the garden that might need attention,” Ava explained. “Isolation Foresting let us detect abnormal sensor readings.”

The Random Forest model was then used to classify garden conditions such as healthy, low-light stress, heat stress, or needing water.

Pramika noted that the models were selected strategically based on the size of the dataset.

“Some models wouldn’t have been efficient for the amount of data we had,” she said. “These were the most optimal.”

Much of the work happened before the model even trained. The students had to clean corrupted values, remove extreme outliers, normalize sensor readings, and prepare the data for classification.

“If you have pH on one scale and temperature on another scale, temperature could dominate the model,” Ava explained. “So we normalized everything between 0 and 1.”

The team relied heavily on Python tools like Pandas, NumPy, and Scikit-learn throughout development. But documentation alone was not enough.

“AI is a great debugger,” Ava said with a laugh.

The students frequently used AI tools to understand errors, brainstorm solutions, and accelerate learning, though they emphasized the importance of validating outputs rather than trusting generated answers blindly.

Pramika also recommended Kaggle as one of the most useful learning resources for beginners interested in machine learning.

“You can find datasets, code examples, and train your own models,” she said. “It’s definitely not impossible to learn on your own.”

One of the biggest technical challenges was that the rover itself had not yet been fully deployed during development. Instead of real environmental data, the machine learning team spent much of the year testing on mock datasets while hardware specifications continued evolving.

“We had to build the model before the robot was even built,” Ava explained.

As sensor parameters changed, parts of the pipeline had to be continuously revised. Still, the students described the experience as valuable because it reflected the reality of large engineering projects where hardware and software systems evolve simultaneously.

That collaboration extended far beyond machine learning.

While the ML team worked on predictions and classification, other students focused on integrating dashboards, APIs, servers, and hardware communication systems that would allow the rover to function as one connected platform.

Tanisha, who worked on dashboard and server integration, focused on connecting live rover data to the visualization systems.

“My main task was connecting the dashboard to the server and making sure the robot ingests live data to give machine learning predictions,” she explained.

Using ArcGIS Pro, Python libraries, and VS Code, she helped create a dashboard capable of displaying both environmental measurements and machine learning outputs in real time.

For Tanisha, one of the biggest learning experiences was working on a project that connected software with physical robotics systems.

“I had only worked on local software projects before,” she said. “This project helped me understand how software has to work together with robotics and wiring teams.”

She described the experience as feeling “like I was a real data scientist on a cross-functional engineering team.”

That same idea — learning by stepping into unfamiliar systems — appeared again in conversations about the project’s backend infrastructure.

For Ananya, the project began with learning nearly everything from scratch.

“Not FastAPI, not Docker, not MQTT,” she said. “I didn’t know how any of the pieces connected.”

Her role focused on building the backend services that sat between the rover and the rest of the system: receiving sensor data, processing it, storing it, and making it available to dashboards and mapping tools.

“The goal was simple in theory,” Ananya explained. “‘Get the data in, process it, store it, send it out.’ Getting there was a different story.”

Much of the challenge came not from writing algorithms, but from troubleshooting infrastructure itself: Docker caching old versions, Python import errors, merge conflicts, broken dependencies, and file structure problems that had no obvious fixes.

“It felt less like debugging and more like detective work,” she said.

At the same time, she was learning FastAPI, Docker, MQTT, and ROS2 simultaneously — often without prior experience in any of them.

Eventually, the backend pipeline began working piece by piece. The server started successfully. MQTT connections stabilized. The ML model executed correctly. The database accepted inserts.

“It was really satisfying to actually see the mock messages come in,” Ananya said, “and see what our rover could relay back to us in real time.”

By the end of development, the backend system was capable of receiving rover data through MQTT, running machine learning predictions, storing information in Azure SQL Edge, and serving REST API endpoints formatted for both dashboards and ESRI mapping systems.

The final layer connecting many of these systems together involved software-hardware integration.

Sanvi, a second-year Computer Science major, worked extensively on the pipeline connecting environmental sensors, the Raspberry Pi 5, and the machine learning infrastructure.

“I had to figure out what MQTT even was,” she said. “How to ingest and format data uniformly, coordinate the Mosquitto broker, and integrate everything into ROS2 launch files.”

Her work stretched across multiple parts of the rover system, including sensor integration, BLE functionality, and the mobile control application.

“SO much Python,” she laughed.

She also worked heavily with Linux terminal commands, ROS2, Adafruit CircuitPython libraries, MQTT, and Dart for mobile app development.

One of the most difficult technical problems she encountered involved the rover’s IMU sensor.

“The core issue was a timing mismatch between the I2C bus clock speed and the IMU’s data-ready rate,” Sanvi explained.

The bus was polling data faster than the IMU could complete its internal conversion cycle, resulting in corrupted sensor readings. After monitoring I2C traffic and diagnosing the timing issue, the team ultimately solved the problem by creating a separate software-defined I2C bus specifically for the IMU so they could independently configure its clock speed.

“The IMU was also really finicky on the breadboard,” she added. “Sometimes we literally had to hold it a certain way to make it work.”

Despite the technical complexity, every student emphasized that collaboration became one of the most rewarding aspects of the project.

Pramika described seeing the complete pipeline function as one of the project’s highlights.

“Seeing the data go through the server, the API, the database, and then become predictions was really cool,” she said.

Sanvi agreed, adding that the people became one of her favorite parts of Geek Team altogether.

“I made a lot of new friends from Team Tech,” she said. “The leads were really supportive.”

When asked who they would recommend Geek Team to, the students gave similar answers: anyone willing to learn.

“This year’s Geek Team challenged all of us,” Sanvi said. “Honestly anyone with curiosity and tenacity to learn something new is perfect.”

Ava echoed the same idea.

“I was very beginner when I started,” she said. “But I learned a lot, and I got stronger with ML.”

For these students, Geek Team became much more than a technical project. It became a space where classroom knowledge turned into real engineering systems — systems involving machine learning, robotics, APIs, dashboards, hardware integration, and environmental impact all working together in one pipeline.