Hackster will be offline on Monday, June 15 from 5pm to 7pm PDT to perform some scheduled maintenance.
Martin KosiLuka Mali
Published © MIT

GunAware - Edge AI gunshot detection

GunAware is a smart edge sensor system that detects gunshots in real time using TinyML and instantly alerts responders via a live monitoring

IntermediateShowcase (no instructions)2 hours33
GunAware - Edge AI gunshot detection

Things used in this project

Story

Read more

Schematics

arhitecture

Code

Backend server

JavaScript
Run with node express
import express from "express";
import cors from "cors";
import sqlite3 from "sqlite3";

const app = express();
const PORT = 8000;

app.use(cors());
app.use(express.json());

const db = new sqlite3.Database("./gunshots.db");

// Create table on startup
db.serialize(() => {
  db.run(`
    CREATE TABLE IF NOT EXISTS detections (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      received_at TEXT NOT NULL,
      node_id TEXT NOT NULL,
      label TEXT,
      confidence REAL,
      peak REAL,
      latitude REAL,
      longitude REAL,
      gateway_rssi REAL,
      gateway_snr REAL,
      raw_payload TEXT
    )
  `);
});

// Helper: run db query as promise
function runQuery(sql, params = []) {
  return new Promise((resolve, reject) => {
    db.run(sql, params, function (err) {
      if (err) reject(err);
      else resolve(this);
    });
  });
}

function allQuery(sql, params = []) {
  return new Promise((resolve, reject) => {
    db.all(sql, params, (err, rows) => {
      if (err) reject(err);
      else resolve(rows);
    });
  });
}

function getQuery(sql, params = []) {
  return new Promise((resolve, reject) => {
    db.get(sql, params, (err, row) => {
      if (err) reject(err);
      else resolve(row);
    });
  });
}


app.post("/webhook/ttn", async (req, res) => {
  try {
    const body = req.body;

    const deviceId =
      body?.end_device_ids?.device_id ||
      body?.device_id ||
      "unknown-node";

    const decoded = body?.uplink_message?.decoded_payload || {};
    const rxMeta = body?.uplink_message?.rx_metadata?.[0] || {};

    const nodeId = decoded.node_id || deviceId;
    const label = decoded.label || "unknown";
    const confidence = Number(decoded.confidence ?? 0);
    const peak = Number(decoded.peak ?? 0);

    // Fixed sensor position or included in payload
    const latitude = Number(decoded.lat ?? decoded.latitude ?? 0);
    const longitude = Number(decoded.lng ?? decoded.longitude ?? 0);

    const gatewayRssi = Number(rxMeta.rssi ?? 0);
    const gatewaySnr = Number(rxMeta.snr ?? 0);

    const receivedAt = new Date().toISOString();

    await runQuery(
      `INSERT INTO detections
      (received_at, node_id, label, confidence, peak, latitude, longitude, gateway_rssi, gateway_snr, raw_payload)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        receivedAt,
        nodeId,
        label,
        confidence,
        peak,
        latitude,
        longitude,
        gatewayRssi,
        gatewaySnr,
        JSON.stringify(body)
      ]
    );

    console.log("Detection stored:", {
      receivedAt,
      nodeId,
      label,
      confidence,
      peak,
      latitude,
      longitude
    });

    res.status(200).json({ ok: true });
  } catch (err) {
    console.error("Webhook error:", err);
    res.status(500).json({ ok: false, error: "Failed to store detection" });
  }
});

// Get all events
app.get("/api/events", async (req, res) => {
  try {
    const limit = Math.min(Number(req.query.limit || 100), 500);

    const rows = await allQuery(
      `SELECT *
       FROM detections
       ORDER BY datetime(received_at) DESC
       LIMIT ?`,
      [limit]
    );

    res.json(rows);
  } catch (err) {
    console.error("Events API error:", err);
    res.status(500).json({ error: "Failed to fetch events" });
  }
});

// Get summary stats
app.get("/api/stats", async (req, res) => {
  try {
    const total = await getQuery(`SELECT COUNT(*) as count FROM detections`);
    const avgConfidence = await getQuery(
      `SELECT AVG(confidence) as avg FROM detections`
    );
    const avgPeak = await getQuery(
      `SELECT AVG(peak) as avg FROM detections`
    );
    const gunshotCount = await getQuery(
      `SELECT COUNT(*) as count FROM detections WHERE label = 'gunshot'`
    );
    const byNode = await allQuery(`
      SELECT node_id, COUNT(*) as count
      FROM detections
      GROUP BY node_id
      ORDER BY count DESC
    `);

    const byDay = await allQuery(`
      SELECT substr(received_at, 1, 10) as day, COUNT(*) as count
      FROM detections
      GROUP BY day
      ORDER BY day DESC
      LIMIT 7
    `);

    res.json({
      totalDetections: total?.count || 0,
      avgConfidence: avgConfidence?.avg || 0,
      avgPeak: avgPeak?.avg || 0,
      gunshotDetections: gunshotCount?.count || 0,
      byNode,
      byDay
    });
  } catch (err) {
    console.error("Stats API error:", err);
    res.status(500).json({ error: "Failed to fetch stats" });
  }
});

// Manual test endpoint for quick demo without TTN
app.post("/api/test-event", async (req, res) => {
  try {
    const {
      node_id = "test-node",
      label = "gunshot",
      confidence = 0.95,
      peak = 0.87,
      latitude = 46.0569,
      longitude = 14.5058
    } = req.body || {};

    const receivedAt = new Date().toISOString();

    await runQuery(
      `INSERT INTO detections
      (received_at, node_id, label, confidence, peak, latitude, longitude, gateway_rssi, gateway_snr, raw_payload)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        receivedAt,
        node_id,
        label,
        confidence,
        peak,
        latitude,
        longitude,
        0,
        0,
        JSON.stringify(req.body || {})
      ]
    );

    res.json({ ok: true });
  } catch (err) {
    console.error("Test event error:", err);
    res.status(500).json({ error: "Failed to insert test event" });
  }
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Frontend

JavaScript
Javascript code for the frontend dashboard
const API_BASE = "https://backend.kbnet.si";
const EVENT_LIMIT = 1000;
const SAME_ORIGIN_BASE = window.location.origin;
const URL_API_BASE_OVERRIDE = new URLSearchParams(window.location.search).get("apiBase");
const RUNTIME_API_BASE = (typeof window.GUNSHOT_API_BASE === "string" && window.GUNSHOT_API_BASE.trim())
  || (typeof URL_API_BASE_OVERRIDE === "string" && URL_API_BASE_OVERRIDE.trim())
  || "";

const API_BASE_CANDIDATES = Array.from(
  new Set([
    RUNTIME_API_BASE,
    API_BASE,
    SAME_ORIGIN_BASE
  ].filter(Boolean))
);

let activeApiBase = API_BASE_CANDIDATES[0] || "";

let map;
let markersLayer;
let selectionLayer;
let alertFxLayer;
let selectedHalo;
let mapHeatLayer;
let selectedEventKey = null;
let hasAutoFitted = false;
let mapWindowMinutes = 60;

let allEvents = [];
let markerByKey = new Map();
let eventByKey = new Map();

let timelineChart;
let nodeChart;
let shotTimelineChart;
let hourlyHeatmapChart;
let showHourlyHeatmap = false;

let lastSeenEventTime = 0;
let lastSeenEventSignature = null;
let lastAlertEventKey = null;
let alertHideTimeout;
let showMapHeatmap = true;
let liveTickerEntries = [];

const timelineGranularitySelect = document.getElementById("timelineGranularity");
const webhookAlert = document.getElementById("webhookAlert");
const webhookAlertText = document.getElementById("webhookAlertText");
const alertSeeMapBtn = document.getElementById("alertSeeMapBtn");
const themeToggle = document.getElementById("themeToggle");
const toggleHourlyHeatmap = document.getElementById("toggleHourlyHeatmap");
const exportPdfBtn = document.getElementById("exportPdfBtn");
const exportCsvBtn = document.getElementById("exportCsvBtn");
const toggleMapHeatmapBtn = document.getElementById("toggleMapHeatmap");
const mapWindowButtons = Array.from(document.querySelectorAll(".map-window-btn"));
const liveTickerTrack = document.getElementById("liveTickerTrack");
const riskStateBadge = document.getElementById("riskStateBadge");
const riskStateText = document.getElementById("riskStateText");
const streak10mDisplay = document.getElementById("streak10m");
const hottestNodeDisplay = document.getElementById("hottestNode");

function normalizeBase(base) {
  return String(base || "").replace(/\/+$/, "");
}

function buildApiUrl(base, path) {
  const normalizedBase = normalizeBase(base);
  const normalizedPath = String(path || "").startsWith("/") ? path : `/${path}`;
  return `${normalizedBase}${normalizedPath}`;
}

function createDetectionClusterLayer() {
  if (typeof L.markerClusterGroup !== "function") {
    return L.layerGroup();
  }

  return L.markerClusterGroup({
    chunkedLoading: true,
    showCoverageOnHover: false,
    spiderfyOnMaxZoom: true,
    disableClusteringAtZoom: 17,
    maxClusterRadius: 56,
    iconCreateFunction(cluster) {
      const count = cluster.getChildCount();
      const toneClass = count < 3 ? "low" : count < 6 ? "mid" : count < 10 ? "high" : "critical";

      return L.divIcon({
        html: `<div class="cluster-badge"><span class="cluster-count">${count}</span></div>`,
        className: `detection-cluster detection-cluster-${toneClass}`,
        iconSize: L.point(48, 48)
      });
    }
  });
}

async function fetchFromApi(path, options) {
  const preferred = activeApiBase;
  const candidates = preferred
    ? [preferred, ...API_BASE_CANDIDATES.filter((base) => base !== preferred)]
    : [...API_BASE_CANDIDATES];

  const failures = [];

  for (const base of candidates) {
    try {
      const response = await fetch(buildApiUrl(base, path), options);
      if (!response.ok) {
        throw new Error(`${path} failed on ${base} (${response.status})`);
      }

      if (base !== activeApiBase) {
        console.info(`Switched API base to ${base}`);
      }

      activeApiBase = base;
      return response;
    }
    catch (error) {
      failures.push(error instanceof Error ? error.message : String(error));
    }
  }

  throw new Error(`All API bases failed for ${path}: ${failures.join(" | ")}`);
}

const shotAxisFormatter = new Intl.DateTimeFormat(undefined, {
  month: "short",
  day: "2-digit",
  hour: "2-digit",
  minute: "2-digit"
});

const chartThemePlugin = {
  id: "chartThemePlugin",
  beforeDraw(chart, _args, opts) {
    const { ctx, chartArea } = chart;
    if (!chartArea || !opts?.backgroundColor) {
      return;
    }

    ctx.save();
    ctx.fillStyle = opts.backgroundColor;
    ctx.fillRect(
      chartArea.left,
      chartArea.top,
      chartArea.right - chartArea.left,
      chartArea.bottom - chartArea.top
    );
    ctx.restore();
  }
};

if (typeof Chart !== "undefined") {
  Chart.register(chartThemePlugin);
}

function getChartThemePalette() {
  const isDark = document.documentElement.getAttribute("data-theme") === "dark";

  if (isDark) {
    return {
      text: "#e2e8f0",
      gridStrong: "rgba(148, 163, 184, 0.2)",
      gridSoft: "rgba(148, 163, 184, 0.12)",
      chartBg: "rgba(15, 23, 42, 0.5)",
      timelineLine: "#fb923c",
      timelineFill: "rgba(251, 146, 60, 0.2)",
      nodeFill: "rgba(45, 212, 191, 0.72)",
      nodeBorder: "#2dd4bf",
      shotPointFill: "rgba(251, 146, 60, 0.75)",
      shotPointBorder: "#fb923c",
      trendLine: "rgba(52, 211, 153, 0.95)",
      heatmapFill: "rgba(251, 146, 60, 0.55)",
      heatmapBorder: "#fb923c"
    };
  }

  return {
    text: "#425346",
    gridStrong: "rgba(39, 59, 43, 0.1)",
    gridSoft: "rgba(39, 59, 43, 0.06)",
    chartBg: "rgba(255, 255, 255, 0.55)",
    timelineLine: "#df5f2d",
    timelineFill: "rgba(223, 95, 45, 0.2)",
    nodeFill: "rgba(26, 109, 90, 0.8)",
    nodeBorder: "#1a6d5a",
    shotPointFill: "rgba(223, 95, 45, 0.75)",
    shotPointBorder: "#df5f2d",
    trendLine: "rgba(26, 109, 90, 0.95)",
    heatmapFill: "rgba(223, 95, 45, 0.6)",
    heatmapBorder: "#df5f2d"
  };
}

function applyChartTheme() {
  const palette = getChartThemePalette();

  if (timelineChart) {
    timelineChart.data.datasets[0].borderColor = palette.timelineLine;
    timelineChart.data.datasets[0].backgroundColor = palette.timelineFill;
    timelineChart.options.plugins.chartThemePlugin = { backgroundColor: palette.chartBg };
    timelineChart.options.scales.y.grid.color = palette.gridStrong;
    timelineChart.options.scales.x.grid.color = palette.gridSoft;
    timelineChart.options.scales.y.ticks.color = palette.text;
    timelineChart.options.scales.x.ticks.color = palette.text;
    timelineChart.update("none");
  }

  if (nodeChart) {
    nodeChart.data.datasets[0].backgroundColor = palette.nodeFill;
    nodeChart.data.datasets[0].borderColor = palette.nodeBorder;
    nodeChart.options.plugins.chartThemePlugin = { backgroundColor: palette.chartBg };
    nodeChart.options.scales.x.grid.color = palette.gridStrong;
    nodeChart.options.scales.x.ticks.color = palette.text;
    nodeChart.options.scales.y.ticks.color = palette.text;
    nodeChart.update("none");
  }

  if (shotTimelineChart) {
    shotTimelineChart.data.datasets[0].pointBackgroundColor = palette.shotPointFill;
    shotTimelineChart.data.datasets[0].pointBorderColor = palette.shotPointBorder;
    shotTimelineChart.data.datasets[1].borderColor = palette.trendLine;
    shotTimelineChart.data.datasets[1].backgroundColor = "rgba(26, 109, 90, 0.2)";
    shotTimelineChart.options.plugins.chartThemePlugin = { backgroundColor: palette.chartBg };
    shotTimelineChart.options.plugins.legend.labels.color = palette.text;
    shotTimelineChart.options.scales.y.grid.color = palette.gridStrong;
    shotTimelineChart.options.scales.x.grid.color = palette.gridSoft;
    shotTimelineChart.options.scales.y.ticks.color = palette.text;
    shotTimelineChart.options.scales.x.ticks.color = palette.text;
    shotTimelineChart.options.scales.y.title.color = palette.text;
    shotTimelineChart.options.scales.x.title.color = palette.text;
    shotTimelineChart.update("none");
  }

  if (hourlyHeatmapChart) {
    if (hourlyHeatmapChart.data.datasets[0]) {
      hourlyHeatmapChart.data.datasets[0].backgroundColor = palette.heatmapFill;
      hourlyHeatmapChart.data.datasets[0].borderColor = palette.heatmapBorder;
    }
    hourlyHeatmapChart.options.plugins.chartThemePlugin = { backgroundColor: palette.chartBg };
    hourlyHeatmapChart.options.scales.y.ticks.color = palette.text;
    hourlyHeatmapChart.options.scales.x.ticks.color = palette.text;
    hourlyHeatmapChart.options.scales.y.title.color = palette.text;
    hourlyHeatmapChart.options.scales.x.title.color = palette.text;
    hourlyHeatmapChart.options.scales.y.grid.color = palette.gridStrong;
    hourlyHeatmapChart.options.scales.x.grid.color = palette.gridSoft;
    hourlyHeatmapChart.update("none");
  }
}

function initMap() {
  map = L.map("map").setView([46.0569, 14.5058], 13); // Ljubljana example

  L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
    maxZoom: 19,
    attribution: "&copy; OpenStreetMap contributors"
  }).addTo(map);

  markersLayer = createDetectionClusterLayer().addTo(map);
  selectionLayer = L.layerGroup().addTo(map);
  alertFxLayer = L.layerGroup().addTo(map);

  if (typeof L.heatLayer === "function") {
    mapHeatLayer = L.heatLayer([], {
      radius: 26,
      blur: 20,
      maxZoom: 17,
      gradient: {
        0.2: "#1a6d5a",
        0.45: "#22c55e",
        0.7: "#f59e0b",
        1.0: "#ef4444"
      }
    }).addTo(map);
  }

  map.on("click", clearSelectedEvent);
}

function escapeHtml(value) {
  return String(value ?? "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/\"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function getRiskState(events) {
  const now = Date.now();
  const recentGunshots = events.filter((event) => {
    const timestamp = toEventTimestamp(event);
    if (timestamp === null || timestamp < now - (5 * 60 * 1000)) {
      return false;
    }
    return String(event.label || "").toLowerCase().includes("gunshot");
  });

  if (recentGunshots.length === 0) {
    return {
      state: "calm",
      score: 0,
      count: 0,
      avgConfidence: 0
    };
  }

  const avgConfidence = recentGunshots.reduce((sum, event) => sum + Number(event.confidence || 0), 0) / recentGunshots.length;
  const velocityFactor = Math.min(1, recentGunshots.length / 8);
  const score = (avgConfidence * 0.75) + (velocityFactor * 0.25);

  if (recentGunshots.length >= 6 || score >= 0.82) {
    return { state: "critical", score, count: recentGunshots.length, avgConfidence };
  }

  if (recentGunshots.length >= 3 || score >= 0.56) {
    return { state: "elevated", score, count: recentGunshots.length, avgConfidence };
  }

  return { state: "calm", score, count: recentGunshots.length, avgConfidence };
}

function updateRiskMood(events) {
  const risk = getRiskState(events);
  document.documentElement.setAttribute("data-risk", risk.state);

  if (riskStateBadge) {
    riskStateBadge.textContent = risk.state.toUpperCase();
  }

  if (riskStateText) {
    if (risk.state === "critical") {
      riskStateText.textContent = `${risk.count} detections in last 5m. Immediate attention advised.`;
    }
    else if (risk.state === "elevated") {
      riskStateText.textContent = `${risk.count} detections in last 5m. Activity rising.`;
    }
    else {
      riskStateText.textContent = "Area stable. Monitoring live signal.";
    }
  }
}

function updateLiveMetrics(events) {
  const now = Date.now();
  const gunshotsLast10m = events.filter((event) => {
    const timestamp = toEventTimestamp(event);
    if (timestamp === null || timestamp < now - (10 * 60 * 1000)) {
      return false;
    }
    return String(event.label || "").toLowerCase().includes("gunshot");
  });

  const nodeCounts = new Map();
  events.forEach((event) => {
    const timestamp = toEventTimestamp(event);
    if (timestamp === null || timestamp < now - (60 * 60 * 1000)) {
      return;
    }

    if (!String(event.label || "").toLowerCase().includes("gunshot")) {
      return;
    }

    const node = event.node_id || "unknown-node";
    nodeCounts.set(node, (nodeCounts.get(node) || 0) + 1);
  });

  let hottestNode = "-";
  let hottestNodeCount = 0;
  nodeCounts.forEach((count, node) => {
    if (count > hottestNodeCount) {
      hottestNodeCount = count;
      hottestNode = node;
    }
  });

  if (streak10mDisplay) {
    streak10mDisplay.textContent = String(gunshotsLast10m.length);
  }

  if (hottestNodeDisplay) {
    hottestNodeDisplay.textContent = hottestNodeCount > 0 ? `${hottestNode} (${hottestNodeCount})` : "-";
  }
}

function upsertTickerEvent(event, eventKey) {
  if (!event || !eventKey) {
    return;
  }

  if (liveTickerEntries.some((entry) => entry.key === eventKey)) {
    return;
  }

  liveTickerEntries.unshift({
    key: eventKey,
    node: event.node_id || "unknown-node",
    label: toDisplayLabel(event.label),
    confidence: Number(event.confidence || 0),
    time: new Date(event.received_at).toLocaleTimeString()
  });

  liveTickerEntries = liveTickerEntries.slice(0, 18);
}

function seedTickerFromEvents(events) {
  if (liveTickerEntries.length > 0) {
    return;
  }

  const latest = [...events]
    .sort((a, b) => new Date(b.received_at).getTime() - new Date(a.received_at).getTime())
    .slice(0, 8);

  latest.forEach((event, index) => {
    const key = buildEventKey(event, index);
    upsertTickerEvent(event, key);
  });
}

function renderLiveTicker() {
  if (!liveTickerTrack) {
    return;
  }

  if (liveTickerEntries.length === 0) {
    liveTickerTrack.innerHTML = "<span class=\"ticker-item\">Waiting for live detections...</span>";
    return;
  }

  const html = liveTickerEntries
    .map((entry) => `
      <button class="ticker-item" type="button" data-event-key="${escapeHtml(entry.key)}">
        ${escapeHtml(entry.node)} | ${escapeHtml(entry.label)} | conf ${entry.confidence.toFixed(2)} | ${escapeHtml(entry.time)}
      </button>
    `)
    .join("");

  liveTickerTrack.innerHTML = `${html}${html}`;
  liveTickerTrack.style.setProperty("--ticker-duration", `${Math.max(24, liveTickerEntries.length * 4.2)}s`);

  liveTickerTrack.querySelectorAll(".ticker-item").forEach((item) => {
    item.addEventListener("click", () => {
      const eventKey = item.dataset.eventKey;
      if (eventKey) {
        focusEventByKey(eventKey, true);
      }
    });
  });
}

function triggerRadarSweep(event) {
  if (!alertFxLayer || !event) {
    return;
  }

  const lat = Number(event.latitude);
  const lng = Number(event.longitude);
  if (Number.isNaN(lat) || Number.isNaN(lng) || lat === 0 || lng === 0) {
    return;
  }

  const center = L.latLng(lat, lng);
  const tone = Number(event.confidence || 0) >= 0.85 ? "#ef4444" : "#fb923c";

  const createPulse = (delayMs) => {
    setTimeout(() => {
      const pulse = L.circleMarker(center, {
        radius: 8,
        color: tone,
        weight: 2,
        fillColor: tone,
        fillOpacity: 0.2,
        opacity: 0.9,
        interactive: false
      }).addTo(alertFxLayer);

      const startTime = performance.now();
      const durationMs = 1100;

      const animate = (now) => {
        const elapsed = now - startTime;
        const progress = Math.min(1, elapsed / durationMs);
        pulse.setRadius(8 + (42 * progress));
        pulse.setStyle({
          opacity: 0.9 * (1 - progress),
          fillOpacity: 0.2 * (1 - progress)
        });

        if (progress < 1) {
          requestAnimationFrame(animate);
        }
        else {
          alertFxLayer.removeLayer(pulse);
        }
      };

      requestAnimationFrame(animate);
    }, delayMs);
  };

  createPulse(0);
  createPulse(180);
}

function updateMapHeatmap(events) {
  if (!map || !mapHeatLayer) {
    return;
  }

  const heatPoints = events
    .map((event) => {
      const lat = Number(event.latitude);
      const lng = Number(event.longitude);
      const confidence = Number(event.confidence || 0);
      if (Number.isNaN(lat) || Number.isNaN(lng) || lat === 0 || lng === 0) {
        return null;
      }

      const weight = Math.min(1, Math.max(0.1, confidence || 0.1));
      return [lat, lng, weight];
    })
    .filter(Boolean);

  mapHeatLayer.setLatLngs(heatPoints);

  if (showMapHeatmap) {
    if (!map.hasLayer(mapHeatLayer)) {
      map.addLayer(mapHeatLayer);
    }
  }
  else if (map.hasLayer(mapHeatLayer)) {
    map.removeLayer(mapHeatLayer);
  }
}

function initThemeToggle() {
  const savedTheme = localStorage.getItem("theme") || "light";
  document.documentElement.setAttribute("data-theme", savedTheme);
  updateThemeToggleIcon(savedTheme);

  if (themeToggle) {
    themeToggle.addEventListener("click", () => {
      const currentTheme = document.documentElement.getAttribute("data-theme") || "light";
      const newTheme = currentTheme === "light" ? "dark" : "light";
      document.documentElement.setAttribute("data-theme", newTheme);
      localStorage.setItem("theme", newTheme);
      updateThemeToggleIcon(newTheme);
      applyChartTheme();
    });
  }
}

function updateThemeToggleIcon(theme) {
  if (themeToggle) {
    themeToggle.textContent = theme === "dark" ? "☀️" : "🌙";
  }
}

function toEventTimestamp(event) {
  const value = new Date(event.received_at).getTime();
  return Number.isNaN(value) ? null : value;
}

function isEventInMapWindow(event) {
  if (mapWindowMinutes === null) {
    return true;
  }

  const eventTs = toEventTimestamp(event);
  if (eventTs === null) {
    return false;
  }

  const cutoffTs = Date.now() - (mapWindowMinutes * 60 * 1000);
  return eventTs >= cutoffTs;
}

function updateMapWindowButtonsActiveState() {
  mapWindowButtons.forEach((button) => {
    const value = button.dataset.window;
    const isActive = (value === "all" && mapWindowMinutes === null)
      || (value !== "all" && Number(value) === mapWindowMinutes);
    button.classList.toggle("active", isActive);
  });
}

function toDisplayLabel(label) {
  return String(label || "unknown").trim() || "unknown";
}

function getThreatLevel(confidence) {
  if (confidence >= 0.85) {
    return "threat-red";
  }
  if (confidence >= 0.7) {
    return "threat-yellow";
  }
  return "threat-green";
}

function buildEventKey(event, index) {
  if (event.id !== undefined && event.id !== null) {
    return `id:${event.id}`;
  }

  return `${event.received_at || "unknown-time"}|${event.node_id || "unknown-node"}|${index}`;
}

function getWeekStart(date) {
  const copy = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
  const day = copy.getUTCDay();
  const diff = day === 0 ? -6 : 1 - day;
  copy.setUTCDate(copy.getUTCDate() + diff);
  return copy;
}

function formatBucketKey(date, granularity) {
  const year = date.getUTCFullYear();
  const month = String(date.getUTCMonth() + 1).padStart(2, "0");
  const day = String(date.getUTCDate()).padStart(2, "0");

  if (granularity === "year") {
    return `${year}`;
  }

  if (granularity === "month") {
    return `${year}-${month}`;
  }

  if (granularity === "week") {
    const weekStart = getWeekStart(date);
    const y = weekStart.getUTCFullYear();
    const jan4 = new Date(Date.UTC(y, 0, 4));
    const firstWeekStart = getWeekStart(jan4);
    const diffMs = weekStart - firstWeekStart;
    const weekNumber = Math.floor(diffMs / (7 * 24 * 60 * 60 * 1000)) + 1;
    return `${y}-W${String(weekNumber).padStart(2, "0")}`;
  }

  return `${year}-${month}-${day}`;
}

function parseBucketToSortableDate(bucket, granularity) {
  if (granularity === "year") {
    return new Date(`${bucket}-01-01T00:00:00Z`);
  }

  if (granularity === "month") {
    return new Date(`${bucket}-01T00:00:00Z`);
  }

  if (granularity === "week") {
    const [yearPart, weekPart] = bucket.split("-W");
    const year = Number(yearPart);
    const week = Number(weekPart);
    const jan4 = new Date(Date.UTC(year, 0, 4));
    const firstWeekStart = getWeekStart(jan4);
    firstWeekStart.setUTCDate(firstWeekStart.getUTCDate() + (week - 1) * 7);
    return firstWeekStart;
  }

  return new Date(`${bucket}T00:00:00Z`);
}

function createChartsIfNeeded() {
  const palette = getChartThemePalette();
  const timelineCanvas = document.getElementById("timelineChart");
  const nodeCanvas = document.getElementById("nodeChart");
  const shotTimelineCanvas = document.getElementById("shotTimelineChart");

  if (!timelineChart) {
    timelineChart = new Chart(timelineCanvas, {
      type: "line",
      data: {
        labels: [],
        datasets: [
          {
            label: "Gunshot detections",
            data: [],
            borderColor: palette.timelineLine,
            backgroundColor: palette.timelineFill,
            fill: true,
            tension: 0.25,
            borderWidth: 3,
            pointRadius: 3,
            pointHoverRadius: 5
          }
        ]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: false,
        plugins: {
          chartThemePlugin: {
            backgroundColor: palette.chartBg
          },
          legend: {
            display: false
          }
        },
        scales: {
          y: {
            beginAtZero: true,
            ticks: {
              precision: 0,
              color: palette.text
            },
            grid: {
              color: palette.gridStrong
            }
          },
          x: {
            ticks: {
              color: palette.text
            },
            grid: {
              color: palette.gridSoft
            }
          }
        }
      }
    });
  }

  if (!nodeChart) {
    nodeChart = new Chart(nodeCanvas, {
      type: "bar",
      data: {
        labels: [],
        datasets: [
          {
            label: "Detections",
            data: [],
            backgroundColor: palette.nodeFill,
            borderColor: palette.nodeBorder,
            borderWidth: 1.5,
            borderRadius: 8
          }
        ]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: false,
        indexAxis: "y",
        plugins: {
          chartThemePlugin: {
            backgroundColor: palette.chartBg
          },
          legend: {
            display: false
          }
        },
        scales: {
          x: {
            beginAtZero: true,
            ticks: {
              precision: 0,
              color: palette.text
            },
            grid: {
              color: palette.gridStrong
            }
          },
          y: {
            ticks: {
              color: palette.text
            },
            grid: {
              display: false
            }
          }
        }
      }
    });
  }

  if (!shotTimelineChart) {
    shotTimelineChart = new Chart(shotTimelineCanvas, {
      type: "scatter",
      data: {
        datasets: [
          {
            label: "Detected shots",
            data: [],
            pointBackgroundColor: palette.shotPointFill,
            pointBorderColor: palette.shotPointBorder,
            pointBorderWidth: 1.4,
            pointRadius(context) {
              return context.raw?.r || 2.6;
            },
            pointHoverRadius(context) {
              return (context.raw?.r || 2.6) + 1.2;
            },
            fill: false,
            showLine: false
          },
          {
            label: "Confidence trend",
            type: "line",
            data: [],
            borderColor: palette.trendLine,
            backgroundColor: "rgba(26, 109, 90, 0.2)",
            borderWidth: 2,
            tension: 0.25,
            pointRadius: 0,
            fill: false
          }
        ]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: false,
        plugins: {
          chartThemePlugin: {
            backgroundColor: palette.chartBg
          },
          legend: {
            display: true,
            labels: {
              usePointStyle: true,
              boxWidth: 8,
              color: palette.text
            }
          },
          tooltip: {
            callbacks: {
              title(items) {
                const x = items[0]?.raw?.x;
                return x ? shotAxisFormatter.format(new Date(x)) : "Detection";
              },
              label(context) {
                if (context.datasetIndex === 1) {
                  return `Trend: ${Number(context.raw.y).toFixed(2)}`;
                }

                const raw = context.raw || {};
                const node = raw.node || "unknown-node";
                const confidence = Number(raw.y || 0).toFixed(2);
                const peak = Number(raw.peak || 0).toFixed(2);
                return `${node} | Confidence: ${confidence} | Peak: ${peak}`;
              }
            }
          }
        },
        scales: {
          y: {
            beginAtZero: true,
            max: 1,
            grid: {
              color: palette.gridStrong
            },
            ticks: {
              color: palette.text
            },
            title: {
              display: true,
              text: "Confidence",
              color: palette.text
            }
          },
          x: {
            type: "linear",
            grid: {
              color: palette.gridSoft
            },
            ticks: {
              autoSkip: true,
              maxTicksLimit: 8,
              color: palette.text,
              callback(value) {
                return shotAxisFormatter.format(new Date(value));
              }
            },
            title: {
              display: true,
              text: "Detection Time",
              color: palette.text
            }
          }
        }
      }
    });
  }
}

function getEventSignature(event) {
  if (!event) {
    return "";
  }

  if (event.id !== undefined && event.id !== null) {
    return `id:${event.id}`;
  }

  return `${event.received_at || "unknown-time"}|${event.node_id || "unknown-node"}|${event.label || "unknown-label"}`;
}

function getLatestEvent(events) {
  let latestEvent = null;
  let latestTime = -1;

  events.forEach((event) => {
    const eventTime = new Date(event.received_at).getTime();
    if (Number.isNaN(eventTime)) {
      return;
    }

    if (eventTime > latestTime) {
      latestTime = eventTime;
      latestEvent = event;
    }
  });

  return latestEvent;
}

function showWebhookAlertForEvent(event, eventKey) {
  if (!webhookAlert || !event) {
    return;
  }

  lastAlertEventKey = eventKey || null;

  if (webhookAlertText) {
    webhookAlertText.textContent = `ALERT: New detection received from /tnt/webhook (${toDisplayLabel(event.label)} | ${event.node_id || "unknown-node"}) at ${new Date(event.received_at).toLocaleTimeString()}`;
  }

  if (alertSeeMapBtn) {
    alertSeeMapBtn.style.display = lastAlertEventKey ? "inline-flex" : "none";
  }

  webhookAlert.classList.remove("is-shocking");
  void webhookAlert.offsetWidth;
  webhookAlert.classList.add("is-visible");
  webhookAlert.classList.add("is-shocking");

  upsertTickerEvent(event, eventKey);
  renderLiveTicker();
  triggerRadarSweep(event);

  if (alertHideTimeout) {
    clearTimeout(alertHideTimeout);
  }

  alertHideTimeout = setTimeout(() => {
    webhookAlert.classList.remove("is-visible");
    webhookAlert.classList.remove("is-shocking");
  }, 8000);
}

function processIncomingAlert(events) {
  const latestEvent = getLatestEvent(events);
  if (!latestEvent) {
    return;
  }

  const latestTime = new Date(latestEvent.received_at).getTime();
  const latestSignature = getEventSignature(latestEvent);
  const latestEventIndex = events.findIndex((event) => event === latestEvent);
  const latestEventKey = latestEventIndex >= 0 ? buildEventKey(latestEvent, latestEventIndex) : null;

  if (!lastSeenEventTime) {
    lastSeenEventTime = latestTime;
    lastSeenEventSignature = latestSignature;
    return;
  }

  const isNewEvent = latestTime > lastSeenEventTime
    || (latestTime === lastSeenEventTime && latestSignature !== lastSeenEventSignature);

  if (isNewEvent) {
    showWebhookAlertForEvent(latestEvent, latestEventKey);
    lastSeenEventTime = latestTime;
    lastSeenEventSignature = latestSignature;
  }
}

function renderTimelineVisuals() {
  createChartsIfNeeded();

  const granularity = timelineGranularitySelect.value;

  const gunshotEvents = allEvents.filter((event) =>
    String(event.label || "").toLowerCase().includes("gunshot")
  );

  const bucketCounts = new Map();
  const nodeCounts = new Map();

  gunshotEvents.forEach((event) => {
    const eventDate = new Date(event.received_at);
    if (Number.isNaN(eventDate.getTime())) {
      return;
    }

...

This file has been truncated, please download it to see its full contents.

arduino code

Arduino
Code running on arduino, handling lora connection and lm library integration.
//#include <ProjectMIS_inferencing.h>
#include <mis_inferencing.h>
#include <PDM.h>

// --------- Hardware & Credentials ---------
#define LORA_POWER_PIN 5
const char* APPEUI = "FEFEFEFEFEFEFEFE";
const char* DEVEUI = "70B3D57ED0076774"; 
const char* APPKEY = "7879060DB75D2CA6F1147E14EA846886";

const char* TARGET_LABEL = "gunshot";
const float MIN_CONFIDENCE = 0.55f;

// --------- Audio Buffers ---------
typedef struct {
    int16_t *buffer;
    uint8_t  buf_ready;
    uint32_t buf_count;
    uint32_t n_samples;
} inference_t;

static inference_t inference;
static signed short sampleBuffer[256];

// Forward Declarations
static void pdm_data_ready_inference_callback(void);
static bool mic_start(uint32_t n_samples);
static int  mic_get_data(size_t offset, size_t length, float *out_ptr);
void sendATCommand(const char* cmd, unsigned long timeoutMs = 2000);

void sendATCommand(const char* cmd, unsigned long timeoutMs) {
  Serial.print("Sending: "); Serial.println(cmd);
  Serial1.print(cmd);
  
  unsigned long start = millis();
  while (millis() - start < timeoutMs) {
    while (Serial1.available()) {
      Serial.print((char)Serial1.read());
    }
  }
}

void setup() {
  // 1. PHYSICAL HARDWARE RESET
  pinMode(LORA_POWER_PIN, OUTPUT);
  digitalWrite(LORA_POWER_PIN, LOW);
  delay(2000);
  digitalWrite(LORA_POWER_PIN, HIGH);
  delay(3000);

  Serial.begin(115200);
  Serial1.begin(9600);
  while (!Serial);

  Serial.println("--- Booting Fully Synchronized EU868 Node ---");
  
  // 2. CONFIGURE CREDENTIALS 
  sendATCommand("AT\r\n");
  
  char buf[128];
  sprintf(buf, "AT+ID=DevEui,\"%s\"\r\n", DEVEUI); sendATCommand(buf);
  sprintf(buf, "AT+ID=AppEui,\"%s\"\r\n", APPEUI); sendATCommand(buf);
  sprintf(buf, "AT+KEY=APPKEY,\"%s\"\r\n", APPKEY); sendATCommand(buf);
  
  sendATCommand("AT+MODE=LWOTAA\r\n");
  sendATCommand("AT+DR=EU868\r\n");
  sendATCommand("AT+CH=NUM,0-7\r\n"); 

  // 3. EXECUTE REPETITIVE OTAA JOIN SEQUENCE 
  bool joined = false;
  int retryCount = 0;
  
  while (!joined) {
    Serial.println("\n=================================");
    Serial.print("Executing Checked Join Request #"); Serial.println(++retryCount);
    Serial.println("=================================");
    
    while(Serial1.available()) Serial1.read(); // Flush buffers
    
    Serial1.print("AT+JOIN\r\n");
    
    unsigned long waitStart = millis();
    String continuousResponse = "";
    
    // Read response window for 20 seconds
    while (millis() - waitStart < 20000) { 
      if (Serial1.available()) {
        String res = Serial1.readString();
        Serial.print(res); 
        continuousResponse += res;
        
        // FIXED CRITICAL CHECK: Match "Network joined" or "Success" based on firmware response
        if (continuousResponse.indexOf("Network joined") != -1 || continuousResponse.indexOf("Success") != -1) { 
          joined = true;
          break; 
        }
      }
    }
    
    if (!joined) { 
      Serial.println("\n[!] Handshake missed on device side. Backing away for 15s...");
      delay(15000); 
    }
  }

  Serial.println("\n>>> TRUE SUCCESS: LoRa Network Active! Booting Microphone... <<<");

  // 4. START MIC
  if (!mic_start(EI_CLASSIFIER_RAW_SAMPLE_COUNT)) {
    Serial.println("Mic Error!");
    while (1);
  }
}

void loop() {
  inference.buf_ready = 0;
  inference.buf_count = 0;
  
  while (!inference.buf_ready) {
    delay(1);
  }

  signal_t signal;
  signal.total_length = EI_CLASSIFIER_RAW_SAMPLE_COUNT;
  signal.get_data     = &mic_get_data;

  ei_impulse_result_t result = { 0 };
  if (run_classifier(&signal, &result, false) == EI_IMPULSE_OK) {
    for (size_t i = 0; i < EI_CLASSIFIER_LABEL_COUNT; i++) {
      
      if (result.classification[i].value > 0.5) {
        Serial.print(result.classification[i].label);
        Serial.print(": ");
        Serial.println(result.classification[i].value);
      }

      if (strcmp(result.classification[i].label, TARGET_LABEL) == 0 && result.classification[i].value >= MIN_CONFIDENCE) {
        Serial.println("\n>>> GUNSHOT DETECTED <<<\n");
        
        // Safe-halt microphone processing interrupts so they don't block serial IO pipelines
        PDM.end();
        
        char payload[64];
        snprintf(payload, sizeof(payload), "AT+MSG=\"s1,gs,%.2f\"\r\n", result.classification[i].value);
        
        while(Serial1.available()) Serial1.read();
        Serial1.print(payload);
        
        // Diagnostic packet tracking window
        unsigned long txWait = millis();
        while(millis() - txWait < 5000) {
          if(Serial1.available()) {
            Serial.print("-> Radio Return Code: ");
            Serial.println(Serial1.readString());
          }
        }
        
        // Restore Microphone tracking
        if (!PDM.begin(1, 41667)) {
          Serial.println("Failed to restart PDM microphone!");
          while(1);
        }
        PDM.setGain(40);
        break; 
      }
    }
  }
}

// --------- Mic Handling ---------

static void pdm_data_ready_inference_callback(void) {
  int r = PDM.read((char*)sampleBuffer, PDM.available());
  if (!inference.buf_ready) {
    for (int i = 0; i < (r >> 1); i++) {
      inference.buffer[inference.buf_count++] = sampleBuffer[i];
      if (inference.buf_count >= inference.n_samples) {
        inference.buf_ready = 1;
        break;
      }
    }
  }
}

static bool mic_start(uint32_t n_samples) {
  inference.buffer = (int16_t*)malloc(n_samples * sizeof(int16_t));
  if (!inference.buffer) return false;
  inference.n_samples = n_samples;
  PDM.onReceive(&pdm_data_ready_inference_callback);
  if (!PDM.begin(1, 41667)) return false;
  PDM.setGain(40);
  return true;
}

static int mic_get_data(size_t offset, size_t length, float *out_ptr) {
  numpy::int16_to_float(&inference.buffer[offset], out_ptr, length);
  return 0;
}

Credits

Martin Kosi
1 project • 0 followers
Luka Mali
27 projects • 30 followers
Maker Pro, prototyping enthusiast, head of MakerLab, a lecturer at the University of Ljubljana, founder.

Comments