Samuel Alexander
Published © GPL3+

Solar Edge AI Forest Watcher for Logging and Poaching

Practical, low-cost edge AI acoustic node with solar power, on-device ML, and MQTT reporting.

IntermediateFull instructions provided7 hours12
Solar Edge AI Forest Watcher for Logging and Poaching

Things used in this project

Hardware components

NextPCB  Custom PCB Board
NextPCB Custom PCB Board
×1
Seeed Studio XIAO ESP32S3 Sense
Seeed Studio XIAO ESP32S3 Sense
×1
SW-420 Vibration sensor
×1
Solar Panel, 1 W
×1
18650 3.7V Li-ion battery
×1

Software apps and online services

Edge Impulse Studio
Edge Impulse Studio
Adafruit IO
KiCad
KiCad
Fusion
Autodesk Fusion

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free
3D Printer (generic)
3D Printer (generic)

Story

Read more

Custom parts and enclosures

forest-baseplate_vnyGndOKiN.stl

Sketchfab still processing.

forest-top-cover_JG9wuQ05Lp.stl

Sketchfab still processing.

tree-mount_nV5APIZlZP.stl

Sketchfab still processing.

aluminium-mount_Tnvd0lN0I4.stl

Sketchfab still processing.

Schematics

screenshot_2025-09-29_at_2_18_37pm_nx3O13sshQ.png

forestwatcher_0Jar0LpzRA.step

forestwatcher_EEgQQvCRF4.kicad_sch

forestwatcher_2wFMKZiTKg.kicad_pcb

Gerber ZIP

Code

forest-watcher.ino

Arduino
/*
 * This code is adapted from Edge Impulse Arduino examples
 * for the Solar Edge AI Forest Watcher for Logging and Poaching
 *
 * Copyright (c) 2022 EdgeImpulse Inc.
 * modified by Samuel Alexander, 2025.

 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
*/

#include <Arduino.h>
#include <Solar_Edge_AI_Forest_Watcher_for_Logging_and_Poaching_inferencing.h>

#include "edge-impulse-sdk/classifier/ei_run_classifier.h"
#include "edge-impulse-sdk/dsp/numpy.hpp"
#include "esp_camera.h"
#include "FS.h"
#include "SD.h"
#include "SPI.h"
#include <ctype.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "AdafruitIO_WiFi.h"
#include <ESP_I2S.h>
#include <ArduinoJson.h>
#include <time.h>

/************* Inline config *************/
// WiFi + Adafruit IO
#define WIFI_SSID     "<YOUR_WIFI_SSID>"
#define WIFI_PASS     "<YOUR_WIFI_PASSWORD>"
#define AIO_USERNAME  "<YOUR_AIO_USERNAME>"
#define AIO_KEY       "<YOUR_AIO_KEY>"

// Node identity: "watcher-01" or "watcher-02"
#define NODE_ID       "watcher-01"

// Debounce
#define SCORE_THRESH  0.70f
#define M_WINDOW      3
#define N_HITS        2
#define COOLDOWN_MS   5000UL

// Heartbeat interval
#define HEARTBEAT_MS  (5UL * 60UL * 1000UL)

// Optional: SD CS override if your core doesnt auto-configure it
// #define SD_CS_PIN   21

// Optional: battery sense; comment out to disable VBAT reading
// #define VBAT_ADC_PIN  A0
// #define VBAT_VREF     3.30f
// #define VBAT_SCALE    2.00f   // divider ratio
/*********** End inline config ***********/

/************* Pins *************/
static const int PIN_TAMPER = D1;   // SW-420 DO
static const int PIN_LED    = D2;   // status LED (active HIGH)
static const int PIN_BUZZ   = D3;   // piezo buzzer (active HIGH)

/************* Adafruit IO *************/
AdafruitIO_WiFi io(AIO_USERNAME, AIO_KEY, WIFI_SSID, WIFI_PASS);
AdafruitIO_Feed *feed_chainsaw = nullptr;
AdafruitIO_Feed *feed_gunshot  = nullptr;
AdafruitIO_Feed *feed_vehicle  = nullptr;
AdafruitIO_Feed *feed_tamper   = nullptr;
AdafruitIO_Feed *feed_status   = nullptr;

/************* Debounce ring *************/
struct Hit { char label[16]; float score; };
static Hit hits[M_WINDOW];
static int hitHead = 0;
static unsigned long tLastEvent = 0;
static unsigned long tLastHB = 0;

/************* Audio capture *************/
static const uint32_t SAMPLE_RATE = 16000; // 16 kHz
static const size_t   RAW_COUNT   = EI_CLASSIFIER_RAW_SAMPLE_COUNT; // expected 32000 for 2 s
static int16_t        g_audio[RAW_COUNT];

static void i2sInit() {
  // XIAO ESP32S3 Sense mic pins per Seeed BSP: BCLK=42, WS=41
  I2S.setAllPins(-1, 42, 41, -1, -1); // MCLK, BCLK, WS, DOUT, DIN
  I2S.begin(PDM_MONO_MODE, SAMPLE_RATE, 16);
}

static size_t captureAudioWindow() {
  size_t needed = RAW_COUNT;
  size_t got = 0;
  while (got < needed) {
    int32_t n = I2S.read((char*)(&g_audio[got]), (needed - got) * sizeof(int16_t));
    if (n > 0) got += (size_t)n / sizeof(int16_t);
    else delay(1);
  }
  return got;
}

static int raw_audio_get_data(size_t offset, size_t length, float *out_ptr) {
  if ((offset + length) > RAW_COUNT) return EIDSP_OUT_OF_BOUNDS;
  for (size_t i = 0; i < length; i++) out_ptr[i] = (float)g_audio[offset + i] / 32768.0f;
  return EIDSP_OK;
}

/************* Camera *************/
#ifndef CAMERA_MODEL_XIAO_ESP32S3
#define CAMERA_MODEL_XIAO_ESP32S3
#endif
#include "camera_pins.h"

static bool cameraInit() {
  camera_config_t config = {};
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer   = LEDC_TIMER_0;
  config.pin_d0       = Y2_GPIO_NUM;
  config.pin_d1       = Y3_GPIO_NUM;
  config.pin_d2       = Y4_GPIO_NUM;
  config.pin_d3       = Y5_GPIO_NUM;
  config.pin_d4       = Y6_GPIO_NUM;
  config.pin_d5       = Y7_GPIO_NUM;
  config.pin_d6       = Y8_GPIO_NUM;
  config.pin_d7       = Y9_GPIO_NUM;
  config.pin_xclk     = XCLK_GPIO_NUM;
  config.pin_pclk     = PCLK_GPIO_NUM;
  config.pin_vsync    = VSYNC_GPIO_NUM;
  config.pin_href     = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn     = PWDN_GPIO_NUM;
  config.pin_reset    = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size   = FRAMESIZE_SVGA; // 800x600
  config.jpeg_quality = 10;             // lower -> higher quality
  config.fb_count     = 1;

  if (esp_camera_init(&config) != ESP_OK) return false;
  sensor_t * s = esp_camera_sensor_get();
  if (s) { s->set_framesize(s, FRAMESIZE_SVGA); s->set_quality(s, 10); }
  return true;
}

static String captureJpegSVGA() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) return String("");

  time_t nowT = time(nullptr);
  struct tm t; bool haveTime = (nowT > 1500000000) && gmtime_r(&nowT, &t);
  char name[32];
  if (haveTime) strftime(name, sizeof(name), "%Y%m%d_%H%M%S.jpg", &t);
  else snprintf(name, sizeof(name), "T%lu.jpg", millis());

  if (!SD.exists("/EVT")) SD.mkdir("/EVT");
  String path = String("/EVT/") + name;
  File f = SD.open(path, FILE_WRITE);
  if (f) { f.write(fb->buf, fb->len); f.close(); }
  esp_camera_fb_return(fb);
  return f ? path : String("");
}

/************* SD *************/
static bool sdOk = false;
static bool sdInit() {
#ifdef SD_CS_PIN
  sdOk = SD.begin(SD_CS_PIN);
#else
  sdOk = SD.begin();
#endif
  if (sdOk && !SD.exists("/EVT")) SD.mkdir("/EVT");
  return sdOk;
}

static void appendCsv(const String &ts, const char* label, float score, int tamper, float vbat, const String &img) {
  if (!sdOk) return;
  File f = SD.open("/event_log.csv", FILE_APPEND);
  if (!f) return;
  // ts,node_id,class,score,tamper,vbat,img
  f.print(ts); f.print(',');
  f.print(NODE_ID); f.print(',');
  f.print(label); f.print(',');
  f.print(score, 2); f.print(',');
  f.print(tamper ? 1 : 0); f.print(',');
  if (isnan(vbat)) f.print(""); else f.print(vbat, 2);
  f.print(','); f.print(img);
  f.print('
');
  f.close();
}

/************* Time *************/
static bool timeValid = false;
static void ntpInit() {
  configTime(0, 0, "pool.ntp.org", "time.nist.gov");
  for (int i = 0; i < 50; i++) { // ~5 s
    time_t nowT = time(nullptr);
    if (nowT > 1700000000) { timeValid = true; break; }
    delay(100);
  }
}

static String tsIso8601() {
  if (timeValid) {
    time_t nowT = time(nullptr);
    struct tm t; gmtime_r(&nowT, &t);
    char buf[32]; strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &t);
    return String(buf);
  }
  unsigned long ms = millis(); unsigned long s = ms / 1000UL;
  char buf[32]; snprintf(buf, sizeof(buf), "1970-01-01T00:%02lu:%02luZ", (s/60UL)%60UL, s%60UL);
  return String(buf);
}

/************* VBAT *************/
static float readBattery() {
#ifdef VBAT_ADC_PIN
  uint16_t raw = analogRead(VBAT_ADC_PIN);
  return (float)raw * VBAT_VREF / 4095.0f * VBAT_SCALE;
#else
  return NAN;
#endif
}

/************* Feedback *************/
static void ledBlink(int times, int onMs, int offMs) {
  for (int i = 0; i < times; i++) { digitalWrite(PIN_LED, HIGH); delay(onMs); digitalWrite(PIN_LED, LOW); delay(offMs); }
}
static void buzz(int ms) { digitalWrite(PIN_BUZZ, HIGH); delay(ms); digitalWrite(PIN_BUZZ, LOW); }

/************* Utils *************/
static bool isTargetLabel(const char *label) {
  return (!strcmp(label, "chainsaw") || !strcmp(label, "vehicle") || !strcmp(label, "gunshot"));
}

static bool debounced(const char* label, float score) {
  if (score < SCORE_THRESH) return false;
  strncpy(hits[hitHead].label, label, sizeof(hits[hitHead].label)-1);
  hits[hitHead].label[sizeof(hits[hitHead].label)-1] = 0;
  hits[hitHead].score = score;
  hitHead = (hitHead + 1) % M_WINDOW;
  int cnt = 0;
  for (int i = 0; i < M_WINDOW; i++) if (!strcmp(hits[i].label, label) && hits[i].score >= SCORE_THRESH) cnt++;
  return cnt >= N_HITS;
}

static String feedKeyFor(const char *label) {
  if (!strcmp(label, "status")) return String("status-") + NODE_ID;
  return String(label) + "-" + NODE_ID; // e.g. chainsaw-watcher-01
}

static AdafruitIO_Feed* feedFor(const char *label) {
  if (!strcmp(label, "chainsaw")) return feed_chainsaw;
  if (!strcmp(label, "gunshot"))  return feed_gunshot;
  if (!strcmp(label, "vehicle"))  return feed_vehicle;
  if (!strcmp(label, "tamper"))   return feed_tamper;
  return feed_status;
}

static void publishEvent(const char* label, float score, int tamper, const String& img, float vbat) {
  StaticJsonDocument<320> doc;
  String ts = tsIso8601();
  doc["ts"]      = ts;
  doc["node_id"] = NODE_ID;
  doc["class"]   = label;
  doc["score"]   = score;
  doc["tamper"]  = tamper ? 1 : 0;
  doc["img"]     = img;
  if (!isnan(vbat)) doc["vbat"] = vbat; else doc["vbat"] = nullptr;

  String payload; serializeJson(doc, payload);
  AdafruitIO_Feed *f = feedFor(label);
  if (f) f->save(payload.c_str());

  // CSV log
  appendCsv(ts, label, score, tamper, vbat, img);
}

/************* Setup / Loop *************/
void setup() {
  pinMode(PIN_LED, OUTPUT); digitalWrite(PIN_LED, LOW);
  pinMode(PIN_BUZZ, OUTPUT); digitalWrite(PIN_BUZZ, LOW);
  pinMode(PIN_TAMPER, INPUT);

  Serial.begin(115200); delay(50);

  // SD and Camera
  sdInit();
  bool camOK = cameraInit();
  if (!camOK) Serial.println("Camera init failed; continuing without images");

  // WiFi + IO
  WiFi.mode(WIFI_STA);
  io.connect();
  unsigned long t0 = millis();
  while (io.status() < AIO_CONNECTED && millis() - t0 < 15000UL) { io.run(); delay(50); }

  // Feeds
  feed_chainsaw = io.feed(feedKeyFor("chainsaw").c_str());
  feed_gunshot  = io.feed(feedKeyFor("gunshot").c_str());
  feed_vehicle  = io.feed(feedKeyFor("vehicle").c_str());
  feed_tamper   = io.feed(feedKeyFor("tamper").c_str());
  feed_status   = io.feed(feedKeyFor("status").c_str());

  // Time
  ntpInit();

  // Audio
  i2sInit();
  memset(hits, 0, sizeof(hits));

  ledBlink(2, 80, 80);
}

void loop() {
  if (io.status() < AIO_CONNECTED) io.connect();
  io.run();

  // Acquire 2 s audio window and infer
  captureAudioWindow();

  signal_t signal; signal.total_length = RAW_COUNT; signal.get_data = &raw_audio_get_data;
  ei_impulse_result_t result = { 0 };
  EI_IMPULSE_ERROR err = run_classifier(&signal, &result, false);
  if (err != EI_IMPULSE_OK) { Serial.printf("run_classifier error %d
", err); delay(10); return; }

  const char* topLabel = "background"; float topScore = 0.0f;
  for (size_t ix = 0; ix < result.classification_count; ix++) {
    const char *lbl = result.classification[ix].label;
    float val = result.classification[ix].value;
    if (isTargetLabel(lbl) && val > topScore) { topScore = val; topLabel = lbl; }
  }

  int tamper = digitalRead(PIN_TAMPER) == HIGH ? 1 : 0;
  bool cooling = (millis() - tLastEvent) < COOLDOWN_MS;
  bool acousticConfirm = isTargetLabel(topLabel) && debounced(topLabel, topScore);
  bool tamperConfirm = tamper == 1;

  if (!cooling && (acousticConfirm || tamperConfirm)) {
    const char* ev = tamperConfirm ? "tamper" : topLabel;
    String img = sdOk ? captureJpegSVGA() : String("");
    float vbat = readBattery();
    publishEvent(ev, tamperConfirm ? 1.0f : topScore, tamper, img, vbat);
    ledBlink(3, 60, 60); buzz(120);
    tLastEvent = millis();
  }

  if (millis() - tLastHB > HEARTBEAT_MS) {
    float vbat = readBattery();
    publishEvent("status", 1.0f, tamper, String(""), vbat);
    tLastHB = millis();
  }

  delay(5);
}
```

Singlefile variant with inline config. Save as forest_watcher.ino.

```cpp
#include <Arduino.h>
#include <Solar_Edge_AI_Forest_Watcher_for_Logging_and_Poaching_inferencing.h>
#include "edge-impulse-sdk/classifier/ei_run_classifier.h"
#include "edge-impulse-sdk/dsp/numpy.hpp"

#include "esp_camera.h"
#include "FS.h"
#include "SD.h"
#include "SPI.h"
#include <ctype.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "AdafruitIO_WiFi.h"
#include <ESP_I2S.h>
#include <ArduinoJson.h>
#include <time.h>

/************* Inline config *************/
// WiFi + Adafruit IO
#define WIFI_SSID     "<YOUR_WIFI_SSID>"
#define WIFI_PASS     "<YOUR_WIFI_PASSWORD>"
#define AIO_USERNAME  "<YOUR_AIO_USERNAME>"
#define AIO_KEY       "<YOUR_AIO_KEY>"

// Node identity: "watcher-01" or "watcher-02"
#define NODE_ID       "watcher-01"

// Debounce
#define SCORE_THRESH  0.85f
#define M_WINDOW      3
#define N_HITS        2
#define COOLDOWN_MS   5000UL

// Heartbeat interval
#define HEARTBEAT_MS  (5UL * 60UL * 1000UL)

// Optional: SD CS override if your core doesnt auto-configure it
// #define SD_CS_PIN   21

// Optional: battery sense; comment out to disable VBAT reading
// #define VBAT_ADC_PIN  A0
// #define VBAT_VREF     3.30f
// #define VBAT_SCALE    2.00f   // divider ratio
/*********** End inline config ***********/

/************* Pins *************/
static const int PIN_TAMPER = D1;   // SW-420 DO
static const int PIN_LED    = D2;   // status LED (active HIGH)
static const int PIN_BUZZ   = D3;   // piezo buzzer (active HIGH)

/************* Adafruit IO *************/
AdafruitIO_WiFi io(AIO_USERNAME, AIO_KEY, WIFI_SSID, WIFI_PASS);
AdafruitIO_Feed *feed_chainsaw = nullptr;
AdafruitIO_Feed *feed_gunshot  = nullptr;
AdafruitIO_Feed *feed_vehicle  = nullptr;
AdafruitIO_Feed *feed_tamper   = nullptr;
AdafruitIO_Feed *feed_status   = nullptr;

/************* Debounce ring *************/
struct Hit { char label[16]; float score; };
static Hit hits[M_WINDOW];
static int hitHead = 0;
static unsigned long tLastEvent = 0;
static unsigned long tLastHB = 0;

/************* Audio capture *************/
static const uint32_t SAMPLE_RATE = 16000; // 16 kHz
static const size_t   RAW_COUNT   = EI_CLASSIFIER_RAW_SAMPLE_COUNT; // expected 32000 for 2 s
static int16_t        g_audio[RAW_COUNT];

static void i2sInit() {
  // XIAO ESP32S3 Sense mic pins per Seeed BSP: BCLK=42, WS=41
  I2S.setAllPins(-1, 42, 41, -1, -1); // MCLK, BCLK, WS, DOUT, DIN
  I2S.begin(PDM_MONO_MODE, SAMPLE_RATE, 16);
}

static size_t captureAudioWindow() {
  size_t needed = RAW_COUNT;
  size_t got = 0;
  while (got < needed) {
    int32_t n = I2S.read((char*)(&g_audio[got]), (needed - got) * sizeof(int16_t));
    if (n > 0) got += (size_t)n / sizeof(int16_t);
    else delay(1);
  }
  return got;
}

static int raw_audio_get_data(size_t offset, size_t length, float *out_ptr) {
  if ((offset + length) > RAW_COUNT) return EIDSP_OUT_OF_BOUNDS;
  for (size_t i = 0; i < length; i++) out_ptr[i] = (float)g_audio[offset + i] / 32768.0f;
  return EIDSP_OK;
}

/************* Camera *************/
#ifndef CAMERA_MODEL_XIAO_ESP32S3
#define CAMERA_MODEL_XIAO_ESP32S3
#endif
#include "camera_pins.h"

static bool cameraInit() {
  camera_config_t config = {};
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer   = LEDC_TIMER_0;
  config.pin_d0       = Y2_GPIO_NUM;
  config.pin_d1       = Y3_GPIO_NUM;
  config.pin_d2       = Y4_GPIO_NUM;
  config.pin_d3       = Y5_GPIO_NUM;
  config.pin_d4       = Y6_GPIO_NUM;
  config.pin_d5       = Y7_GPIO_NUM;
  config.pin_d6       = Y8_GPIO_NUM;
  config.pin_d7       = Y9_GPIO_NUM;
  config.pin_xclk     = XCLK_GPIO_NUM;
  config.pin_pclk     = PCLK_GPIO_NUM;
  config.pin_vsync    = VSYNC_GPIO_NUM;
  config.pin_href     = HREF_GPIO_NUM;
  config.pin_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn     = PWDN_GPIO_NUM;
  config.pin_reset    = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size   = FRAMESIZE_SVGA; // 800x600
  config.jpeg_quality = 10;             // lower -> higher quality
  config.fb_count     = 1;

  if (esp_camera_init(&config) != ESP_OK) return false;
  sensor_t * s = esp_camera_sensor_get();
  if (s) { s->set_framesize(s, FRAMESIZE_SVGA); s->set_quality(s, 10); }
  return true;
}

static String captureJpegSVGA() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) return String("");

  time_t nowT = time(nullptr);
  struct tm t; bool haveTime = (nowT > 1500000000) && gmtime_r(&nowT, &t);
  char name[32];
  if (haveTime) strftime(name, sizeof(name), "%Y%m%d_%H%M%S.jpg", &t);
  else snprintf(name, sizeof(name), "T%lu.jpg", millis());

  if (!SD.exists("/EVT")) SD.mkdir("/EVT");
  String path = String("/EVT/") + name;
  File f = SD.open(path, FILE_WRITE);
  if (f) { f.write(fb->buf, fb->len); f.close(); }
  esp_camera_fb_return(fb);
  return f ? path : String("");
}

/************* SD *************/
static bool sdOk = false;
static bool sdInit() {
#ifdef SD_CS_PIN
  sdOk = SD.begin(SD_CS_PIN);
#else
  sdOk = SD.begin();
#endif
  if (sdOk && !SD.exists("/EVT")) SD.mkdir("/EVT");
  return sdOk;
}

static void appendCsv(const String &ts, const char* label, float score, int tamper, float vbat, const String &img) {
  if (!sdOk) return;
  File f = SD.open("/event_log.csv", FILE_APPEND);
  if (!f) return;
  // ts,node_id,class,score,tamper,vbat,img
  f.print(ts); f.print(',');
  f.print(NODE_ID); f.print(',');
  f.print(label); f.print(',');
  f.print(score, 2); f.print(',');
  f.print(tamper ? 1 : 0); f.print(',');
  if (isnan(vbat)) f.print(""); else f.print(vbat, 2);
  f.print(','); f.print(img);
  f.print('
');
  f.close();
}

/************* Time *************/
static bool timeValid = false;
static void ntpInit() {
  configTime(0, 0, "pool.ntp.org", "time.nist.gov");
  for (int i = 0; i < 50; i++) { // ~5 s
    time_t nowT = time(nullptr);
    if (nowT > 1700000000) { timeValid = true; break; }
    delay(100);
  }
}

static String tsIso8601() {
  if (timeValid) {
    time_t nowT = time(nullptr);
    struct tm t; gmtime_r(&nowT, &t);
    char buf[32]; strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &t);
    return String(buf);
  }
  unsigned long ms = millis(); unsigned long s = ms / 1000UL;
  char buf[32]; snprintf(buf, sizeof(buf), "1970-01-01T00:%02lu:%02luZ", (s/60UL)%60UL, s%60UL);
  return String(buf);
}

/************* VBAT *************/
static float readBattery() {
#ifdef VBAT_ADC_PIN
  uint16_t raw = analogRead(VBAT_ADC_PIN);
  return (float)raw * VBAT_VREF / 4095.0f * VBAT_SCALE;
#else
  return NAN;
#endif
}

/************* Feedback *************/
static void ledBlink(int times, int onMs, int offMs) {
  for (int i = 0; i < times; i++) { digitalWrite(PIN_LED, HIGH); delay(onMs); digitalWrite(PIN_LED, LOW); delay(offMs); }
}
static void buzz(int ms) { digitalWrite(PIN_BUZZ, HIGH); delay(ms); digitalWrite(PIN_BUZZ, LOW); }

/************* Utils *************/
static bool isTargetLabel(const char *label) {
  return (!strcmp(label, "chainsaw") || !strcmp(label, "vehicle") || !strcmp(label, "gunshot"));
}

static bool debounced(const char* label, float score) {
  if (score < SCORE_THRESH) return false;
  strncpy(hits[hitHead].label, label, sizeof(hits[hitHead].label)-1);
  hits[hitHead].label[sizeof(hits[hitHead].label)-1] = 0;
  hits[hitHead].score = score;
  hitHead = (hitHead + 1) % M_WINDOW;
  int cnt = 0;
  for (int i = 0; i < M_WINDOW; i++) if (!strcmp(hits[i].label, label) && hits[i].score >= SCORE_THRESH) cnt++;
  return cnt >= N_HITS;
}

static String feedKeyFor(const char *label) {
  if (!strcmp(label, "status")) return String("status-") + NODE_ID;
  return String(label) + "-" + NODE_ID; // e.g. chainsaw-watcher-01
}

static AdafruitIO_Feed* feedFor(const char *label) {
  if (!strcmp(label, "chainsaw")) return feed_chainsaw;
  if (!strcmp(label, "gunshot"))  return feed_gunshot;
  if (!strcmp(label, "vehicle"))  return feed_vehicle;
  if (!strcmp(label, "tamper"))   return feed_tamper;
  return feed_status;
}

static void publishEvent(const char* label, float score, int tamper, const String& img, float vbat) {
  StaticJsonDocument<320> doc;
  String ts = tsIso8601();
  doc["ts"]      = ts;
  doc["node_id"] = NODE_ID;
  doc["class"]   = label;
  doc["score"]   = score;
  doc["tamper"]  = tamper ? 1 : 0;
  doc["img"]     = img;
  if (!isnan(vbat)) doc["vbat"] = vbat; else doc["vbat"] = nullptr;

  String payload; serializeJson(doc, payload);
  AdafruitIO_Feed *f = feedFor(label);
  if (f) f->save(payload.c_str());

  // CSV log
  appendCsv(ts, label, score, tamper, vbat, img);
}

/************* Setup / Loop *************/
void setup() {
  pinMode(PIN_LED, OUTPUT); digitalWrite(PIN_LED, LOW);
  pinMode(PIN_BUZZ, OUTPUT); digitalWrite(PIN_BUZZ, LOW);
  pinMode(PIN_TAMPER, INPUT);

  Serial.begin(115200); delay(50);

  // SD and Camera
  sdInit();
  bool camOK = cameraInit();
  if (!camOK) Serial.println("Camera init failed; continuing without images");

  // WiFi + IO
  WiFi.mode(WIFI_STA);
  io.connect();
  unsigned long t0 = millis();
  while (io.status() < AIO_CONNECTED && millis() - t0 < 15000UL) { io.run(); delay(50); }

  // Feeds
  feed_chainsaw = io.feed(feedKeyFor("chainsaw").c_str());
  feed_gunshot  = io.feed(feedKeyFor("gunshot").c_str());
  feed_vehicle  = io.feed(feedKeyFor("vehicle").c_str());
  feed_tamper   = io.feed(feedKeyFor("tamper").c_str());
  feed_status   = io.feed(feedKeyFor("status").c_str());

  // Time
  ntpInit();

  // Audio
  i2sInit();
  memset(hits, 0, sizeof(hits));

  ledBlink(2, 80, 80);
}

void loop() {
  if (io.status() < AIO_CONNECTED) io.connect();
  io.run();

  // Acquire 2 s audio window and infer
  captureAudioWindow();

  signal_t signal; signal.total_length = RAW_COUNT; signal.get_data = &raw_audio_get_data;
  ei_impulse_result_t result = { 0 };
  EI_IMPULSE_ERROR err = run_classifier(&signal, &result, false);
  if (err != EI_IMPULSE_OK) { Serial.printf("run_classifier error %d
", err); delay(10); return; }

  const char* topLabel = "background"; float topScore = 0.0f;
  for (size_t ix = 0; ix < result.classification_count; ix++) {
    const char *lbl = result.classification[ix].label;
    float val = result.classification[ix].value;
    if (isTargetLabel(lbl) && val > topScore) { topScore = val; topLabel = lbl; }
  }

  int tamper = digitalRead(PIN_TAMPER) == HIGH ? 1 : 0;
  bool cooling = (millis() - tLastEvent) < COOLDOWN_MS;
  bool acousticConfirm = isTargetLabel(topLabel) && debounced(topLabel, topScore);
  bool tamperConfirm = tamper == 1;

  if (!cooling && (acousticConfirm || tamperConfirm)) {
    const char* ev = tamperConfirm ? "tamper" : topLabel;
    String img = sdOk ? captureJpegSVGA() : String("");
    float vbat = readBattery();
    publishEvent(ev, tamperConfirm ? 1.0f : topScore, tamper, img, vbat);
    ledBlink(3, 60, 60); buzz(120);
    tLastEvent = millis();
  }

  if (millis() - tLastHB > HEARTBEAT_MS) {
    float vbat = readBattery();
    publishEvent("status", 1.0f, tamper, String(""), vbat);
    tLastHB = millis();
  }

  delay(5);
}

Edge Impulse Arduino Deployment Export ZIP

Arduino
No preview (download only).

Credits

Samuel Alexander
6 projects • 32 followers

Comments