// src/main.cpp // ESP32-C6 Sparrow: supports LTR303 or LTR308 digital light sensors // - Auto-detects which sensor is present // - LTR303: uses CH0 counts (robust brightness metric) // - LTR308: computes approximate lux using datasheet scaling // - Normalizes, runs online k-means, and maps to room-state labels // // PlatformIO deps (platformio.ini): // lib_deps = // adafruit/Adafruit LTR329 and LTR303@^3.0.1 // https://github.com/DFRobot/DFRobot_LTR308.git #include <Arduino.h> #include <Wire.h> #include <Preferences.h> #include <math.h> // --- Sparrow I2C pins --- static const int SDA_PIN = 21; static const int SCL_PIN = 22; // Optional INT pin (not required for basic reads) static const int LTR_INT_PIN = 15; // --- Sensor libraries --- #include <Adafruit_LTR329_LTR303.h> // LTR303 @ 0x29 #include <DFRobot_LTR308.h> // LTR308 @ 0x53 // ---------------- Sensor abstraction ---------------- enum class SensorType { NONE, LTR303, LTR308 }; SensorType gType = SensorType::NONE; Adafruit_LTR303 g303; DFRobot_LTR308 g308; // Track LTR308 configuration we apply (so lux math is consistent) DFRobot_LTR308::eResolution_t g308_res = DFRobot_LTR308::eConversion_100ms_18b; // Numeric gain we set {1,3,6,9,18} uint8_t g308_gain = 3; // Try to initialize either sensor bool sensorBegin() { // Try LTR303 first (I2C addr 0x29) if (g303.begin(&Wire)) { g303.setGain(LTR3XX_GAIN_1); g303.setIntegrationTime(LTR3XX_INTEGTIME_100); g303.setMeasurementRate(LTR3XX_MEASRATE_100); gType = SensorType::LTR303; return true; } // Then LTR308 (I2C addr 0x53) if (g308.begin()) { g308_res = DFRobot_LTR308::eConversion_100ms_18b; g308_gain = 3; // 3x is a good default indoors g308.setMeasurementRate(g308_res, DFRobot_LTR308::eRate_100ms); g308.setGain(DFRobot_LTR308::eGain_3X); g308.setPowerUp(); gType = SensorType::LTR308; return true; } gType = SensorType::NONE; return false; } // Read brightness from whichever sensor is present // - LTR303: returns CH0 counts (Visible+IR). Stable and monotonic; great for clustering. // (If you want slight IR reduction, see the commented Option B below.) // - LTR308: returns a lux estimate using the datasheet scaling based on our config. float readLux() { if (gType == SensorType::LTR303) { uint16_t ch0 = 0, ch1 = 0; // Short wait to reduce stale reads uint32_t t0 = millis(); while (!g303.newDataAvailable() && (millis() - t0) < 20) { delay(1); } if (!g303.readBothChannels(ch0, ch1)) return 0.0f; // Option A (recommended): use CH0 counts directly float brightness = (float)ch0; // Option B (IR-reduced proxy): uncomment if desired // float brightness = (float)ch0 - 0.6f * (float)ch1; // if (brightness < 0) brightness = 0; return brightness; // counts (not true lux) — we normalize later } if (gType == SensorType::LTR308) { // Datasheet approximation: Lux ≈ 0.6 * counts / (gain * int_factor) // int_factor depends on resolution (integration time) uint32_t counts = g308.getData(); float intF = 1.0f; switch (g308_res) { case DFRobot_LTR308::eConversion_25ms_16b: intF = 0.25f; break; case DFRobot_LTR308::eConversion_50ms_17b: intF = 0.50f; break; case DFRobot_LTR308::eConversion_100ms_18b: intF = 1.00f; break; case DFRobot_LTR308::eConversion_200ms_19b: intF = 2.00f; break; case DFRobot_LTR308::eConversion_400ms_20b: intF = 4.00f; break; default: break; } float gain = 3.0f; // numeric gain we set switch (g308_gain) { case 1: gain = 1.0f; break; case 3: gain = 3.0f; break; case 6: gain = 6.0f; break; case 9: gain = 9.0f; break; case 18: gain = 18.0f; break; default: break; } return (gain > 0 && intF > 0) ? (0.6f * (float)counts / (gain * intF)) : 0.0f; } return 0.0f; } // ---------------- Unsupervised clustering + labeling ---------------- // Sampling / smoothing constexpr uint32_t SAMPLE_MS = 100; // 10 Hz constexpr float EMA_ALPHA = 0.3f; // EMA smoothing // Online K-Means config constexpr int K = 5; // clusters for: night / shade / lights / sun / transition constexpr int TRAIN_SAMPLES = 400; // ~40 s training at 10 Hz // Short window variability (detect movement/changes) constexpr int WIN = 30; // 3 s at 10 Hz struct ClusterStats { unsigned long n = 0; float mean = 0.0f; // on normalized scale float M2 = 0.0f; }; ClusterStats clusters[K]; float ring[WIN]; int rpos=0; int rcount=0; Preferences prefs; String clusterLabel[K]; // manual overrides via Serial REPL // Utils static inline float clamp01(float x){ return x < 0 ? 0 : (x > 1 ? 1 : x); } static inline float fastAbs(float x){ return x < 0 ? -x : x; } void onlineUpdate(ClusterStats& c, float x) { c.n++; float delta = x - c.mean; c.mean += delta / c.n; c.M2 += delta * (x - c.mean); } float stddev(const ClusterStats& c) { if (c.n < 2) return 0.0f; return sqrtf(c.M2 / (c.n - 1)); } int nearestCluster(float x, float *outDist=nullptr) { int argmin = 0; float dmin = 1e9; for (int k=0;k<K;k++){ float d = (clusters[k].n ? fastAbs(x - clusters[k].mean) : 0); if (d < dmin){ dmin = d; argmin = k; } } if (outDist) *outDist = dmin; return argmin; } void seedClusters(){ for(int k=0;k<K;k++){ clusters[k].n = 1; clusters[k].mean = (k + 1) / float(K + 1); // 0.17..0.83 for K=5 clusters[k].M2 = 0; } } float stdev_window(){ if (rcount < 5) return 0; float mean=0; for(int i=0;i<rcount;i++) mean += ring[i]; mean /= rcount; float m2=0; for(int i=0;i<rcount;i++){ float d=ring[i]-mean; m2+=d*d; } return sqrtf(m2/rcount); } // Persist manual labels void loadLabels(){ prefs.begin("labels", true); for(int i=0;i<K;i++){ clusterLabel[i] = prefs.getString(String(i).c_str(), ""); } prefs.end(); } void saveLabels(){ prefs.begin("labels", false); for(int i=0;i<K;i++){ prefs.putString(String(i).c_str(), clusterLabel[i]); } prefs.end(); } // Heuristic label if no manual override present String inferLabel(int /*k*/, float normLux, float sdw){ // Tune thresholds to your environment if needed if (normLux < 0.20f) return "night"; if (normLux > 0.85f && sdw < 0.02f) return "full_sun"; if (normLux > 0.25f && sdw > 0.06f) return "lights_on"; if (normLux > 0.45f && normLux < 0.85f && sdw < 0.03f) return "shade/day_indirect"; return "transition"; } // Log normalization to squeeze wide dynamic range into [0,1] float normalize_lux(float v){ // For LTR303: "counts" scale; for LTR308: lux. Both get log-compressed. // Using 64k as a generous upper bound reference. const float denom = log10f(1.0f + 64000.0f); return clamp01( log10f(1.0f + v) / denom ); } // Runtime state uint32_t lastSample=0; int trainCount = 0; float ema = -1.0f; void setup(){ Serial.begin(115200); delay(300); pinMode(LTR_INT_PIN, INPUT_PULLUP); // optional Wire.begin(SDA_PIN, SCL_PIN); // Sparrow I2C pins bool ok = sensorBegin(); if (!ok){ Serial.println("No LTR303 (0x29) or LTR308 (0x53) detected on I2C."); } else { Serial.print("Sensor: "); Serial.println(gType==SensorType::LTR303 ? "LTR303" : "LTR308"); } seedClusters(); loadLabels(); Serial.println("\nSparrow light-state (unsupervised k-means)"); Serial.println("Commands: setlabel <k> <name> | savelabels | labels"); } void loop(){ uint32_t now = millis(); if (now - lastSample < SAMPLE_MS) return; lastSample = now; // 1) Read raw brightness float raw = readLux(); // 2) Normalize (log scale), then EMA smooth float x = normalize_lux(raw); if (ema < 0) ema = x; ema = EMA_ALPHA * x + (1 - EMA_ALPHA) * ema; // 3) Update short window buffer ring[rpos] = ema; rpos = (rpos+1) % WIN; if (rcount < WIN) rcount++; // 4) Training phase if (trainCount < TRAIN_SAMPLES){ int k = nearestCluster(ema); onlineUpdate(clusters[k], ema); trainCount++; if (trainCount % 50 == 0){ Serial.printf("Training %d/%d means:", trainCount, TRAIN_SAMPLES); for (int i=0;i<K;i++) Serial.printf(" %.3f", clusters[i].mean); Serial.println(); } return; } // 5) Inference float dist; int k = nearestCluster(ema, &dist); float sd = stddev(clusters[k]); float sdw = stdev_window(); // Choose label (manual override if set, else heuristic) String label = clusterLabel[k].length() ? clusterLabel[k] : inferLabel(k, ema, sdw); // Hysteresis so labels don't flicker static String lastLabel=""; static String pending=""; static int pendCount=0; if (label != lastLabel){ if (label == pending) { pendCount++; } else { pending = label; pendCount = 1; } if (pendCount >= 2){ lastLabel = label; pendCount = 0; } } String stableLabel = lastLabel; // Slow adaptation so clusters follow daylight drift onlineUpdate(clusters[k], ema); // 6) Log Serial.printf("[%s] raw=%.1f norm=%.3f ema=%.3f k=%d mean=%.3f sd=%.3f sdWin=%.3f label=%s\n", (gType==SensorType::LTR303 ? "LTR303" : gType==SensorType::LTR308 ? "LTR308" : "NONE"), raw, x, ema, k, clusters[k].mean, sd, sdw, stableLabel.c_str()); // 7) Tiny serial REPL if (Serial.available()){ String cmd = Serial.readStringUntil('\n'); cmd.trim(); if (cmd.startsWith("setlabel")){ int sp1 = cmd.indexOf(' '), sp2 = cmd.indexOf(' ', sp1+1); int idx = cmd.substring(sp1+1, sp2).toInt(); String name = cmd.substring(sp2+1); if (idx>=0 && idx<K){ clusterLabel[idx]=name; Serial.println("OK"); } else { Serial.println("ERR"); } } else if (cmd=="savelabels"){ saveLabels(); Serial.println("saved"); } else if (cmd=="labels"){ for(int i=0;i<K;i++){ Serial.printf("k=%d mean=%.3f n=%lu label=\"%s\"\n", i, clusters[i].mean, clusters[i].n, clusterLabel[i].c_str()); } } } }