Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,25 @@ jobs:
- name: Lint
run: ruff check backend/

backend-test:
name: Backend Tests
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install Dependencies
run: pip install -r backend/requirements.txt

- name: Run Tests
run: python -m pytest backend/tests/ -v --tb=short

frontend-build:
name: Frontend Build
runs-on: ubuntu-latest
Expand Down
Binary file modified .gitignore
Binary file not shown.
139 changes: 139 additions & 0 deletions backend/app/export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""
GridShield Backend — Data Export Routes
CSV export endpoints for readings, alerts, and anomalies.
"""

import csv
import io
from datetime import datetime

from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session

from . import models
from .database import get_db

router = APIRouter(prefix="/api/export", tags=["Export"])


def _csv_response(rows: list[dict], filename: str) -> StreamingResponse:
"""Build a StreamingResponse with CSV content."""
if not rows:
output = io.StringIO()
output.write("")
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)


# ============================================================================
# Export Readings
# ============================================================================
@router.get("/readings")
def export_readings(
meter_id: int | None = None,
limit: int = Query(default=1000, le=10000),
db: Session = Depends(get_db),
):
"""Export meter readings as CSV."""
query = db.query(models.MeterReading)
if meter_id is not None:
query = query.filter(models.MeterReading.meter_id == meter_id)
results = query.order_by(models.MeterReading.timestamp.desc()).limit(limit).all()

rows = [
{
"id": r.id,
"meter_id": r.meter_id,
"timestamp": r.timestamp.isoformat() if r.timestamp else "",
"energy_wh": r.energy_wh,
"voltage_mv": r.voltage_mv,
"current_ma": r.current_ma,
"power_factor": r.power_factor,
"phase": r.phase,
}
for r in results
]

ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
return _csv_response(rows, f"gridshield_readings_{ts}.csv")


# ============================================================================
# Export Alerts
# ============================================================================
@router.get("/alerts")
def export_alerts(
meter_id: int | None = None,
limit: int = Query(default=1000, le=10000),
db: Session = Depends(get_db),
):
"""Export tamper alerts as CSV."""
query = db.query(models.TamperAlert)
if meter_id is not None:
query = query.filter(models.TamperAlert.meter_id == meter_id)
results = query.order_by(models.TamperAlert.timestamp.desc()).limit(limit).all()

rows = [
{
"id": a.id,
"meter_id": a.meter_id,
"timestamp": a.timestamp.isoformat() if a.timestamp else "",
"tamper_type": a.tamper_type,
"severity": a.severity,
"acknowledged": a.acknowledged,
}
for a in results
]

ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
return _csv_response(rows, f"gridshield_alerts_{ts}.csv")


# ============================================================================
# Export Anomalies
# ============================================================================
@router.get("/anomalies")
def export_anomalies(
meter_id: int | None = None,
limit: int = Query(default=1000, le=10000),
db: Session = Depends(get_db),
):
"""Export anomaly logs as CSV."""
query = db.query(models.AnomalyLog)
if meter_id is not None:
query = query.filter(models.AnomalyLog.meter_id == meter_id)
results = query.order_by(models.AnomalyLog.timestamp.desc()).limit(limit).all()

rows = [
{
"id": a.id,
"meter_id": a.meter_id,
"timestamp": a.timestamp.isoformat() if a.timestamp else "",
"anomaly_type": a.anomaly_type,
"severity": a.severity,
"current_value": a.current_value,
"expected_value": a.expected_value,
"deviation_percent": a.deviation_percent,
"confidence": a.confidence,
}
for a in results
]

ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
return _csv_response(rows, f"gridshield_anomalies_{ts}.csv")
16 changes: 16 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
from fastapi.middleware.cors import CORSMiddleware

from .database import Base, engine
<<<<<<< HEAD
from .export import router as export_router
=======
>>>>>>> 469b660da70c38354fe5127353f451559b605a7f
from .meters import router as meters_router
from .routes import router

Expand All @@ -25,7 +29,11 @@ async def lifespan(app: FastAPI):
description="REST API for GridShield AMI Security System — "
"Meter data ingestion, tamper alerts, anomaly monitoring, "
"and fleet management.",
<<<<<<< HEAD
version="3.2.0",
=======
version="3.1.0",
>>>>>>> 469b660da70c38354fe5127353f451559b605a7f
lifespan=lifespan,
)

Expand All @@ -41,13 +49,21 @@ async def lifespan(app: FastAPI):
# Routes
app.include_router(router)
app.include_router(meters_router)
<<<<<<< HEAD
app.include_router(export_router)
=======
>>>>>>> 469b660da70c38354fe5127353f451559b605a7f


@app.get("/", tags=["Root"])
def root():
"""Health check."""
return {
"name": "GridShield API",
<<<<<<< HEAD
"version": "3.2.0",
=======
"version": "3.1.0",
>>>>>>> 469b660da70c38354fe5127353f451559b605a7f
"status": "running",
}
Binary file modified backend/test_gridshield.db
Binary file not shown.
Binary file modified backend/test_results.txt
Binary file not shown.
78 changes: 78 additions & 0 deletions backend/tests/test_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""
Tests for CSV export endpoints.
"""

from fastapi.testclient import TestClient


def test_export_readings_empty(client: TestClient):
"""Export readings CSV when no data exists — returns empty CSV."""
resp = client.get("/api/export/readings")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/csv")


def test_export_readings_with_data(client: TestClient):
"""Export readings CSV with data — returns proper CSV rows."""
# Insert a reading
client.post("/api/meter-data", json={
"meter_id": 99001,
"energy_wh": 500,
"voltage_mv": 220000,
"current_ma": 2000,
})
resp = client.get("/api/export/readings")
assert resp.status_code == 200
lines = resp.text.strip().split("\n")
assert len(lines) >= 2 # header + at least 1 row
assert "meter_id" in lines[0]
assert "energy_wh" in lines[0]
assert "99001" in lines[1]


def test_export_alerts_empty(client: TestClient):
"""Export alerts CSV when no data exists."""
resp = client.get("/api/export/alerts")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/csv")


def test_export_alerts_with_data(client: TestClient):
"""Export alerts CSV with data."""
client.post("/api/tamper-alert", json={
"meter_id": 99002,
"tamper_type": "CasingOpened",
"severity": 3,
})
resp = client.get("/api/export/alerts")
assert resp.status_code == 200
lines = resp.text.strip().split("\n")
assert len(lines) >= 2
assert "tamper_type" in lines[0]
assert "CasingOpened" in lines[1]


def test_export_anomalies_empty(client: TestClient):
"""Export anomalies CSV when no data exists."""
resp = client.get("/api/export/anomalies")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/csv")


def test_export_anomalies_with_data(client: TestClient):
"""Export anomalies CSV with data."""
client.post("/api/anomalies", json={
"meter_id": 99003,
"anomaly_type": "UnexpectedSpike",
"severity": "high",
"current_value": 1500.0,
"expected_value": 500.0,
"deviation_percent": 200.0,
"confidence": 95,
})
resp = client.get("/api/export/anomalies")
assert resp.status_code == 200
lines = resp.text.strip().split("\n")
assert len(lines) >= 2
assert "anomaly_type" in lines[0]
assert "UnexpectedSpike" in lines[1]
37 changes: 37 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
<<<<<<< HEAD
## [3.2.0] - 2026-03-07

### Added
- **Data Export API** (`backend/app/export.py`):
- `GET /api/export/readings` — Export meter readings as CSV.
- `GET /api/export/alerts` — Export tamper alerts as CSV.
- `GET /api/export/anomalies` — Export anomaly logs as CSV.
- All endpoints support `meter_id` filtering and `StreamingResponse`.
- **Frontend Fleet Integration**:
- Fleet page refactored to use backend Meter CRUD API (`GET /api/meters`).
- Meter registration modal (`POST /api/meters`).
- Meter delete with confirmation (`DELETE /api/meters/{id}`).
- Meter status indicators (online/offline/tampered).
- Meter detail modal with statistics from `GET /api/meters/{id}/stats`.
- CSV export buttons on Dashboard, Alerts, and Anomalies pages.
- **Evidence Preservation** (`firmware/include/common/forensics/evidence_store.hpp`):
- `EvidenceStore` — circular buffer for 32 forensic evidence snapshots.
- `SensorSnapshot` — captures raw sensor state (energy, voltage, current, temperature, accelerometer).
- Hash chain integrity — each snapshot linked to previous via FNV-1a hash.
- `verify_chain()` — validates entire evidence chain integrity.
- **Backend Test Suite**:
- 6 new export endpoint tests (`backend/tests/test_export.py`).
- Total backend tests: 37+.
- **Firmware Tests**:
- 8 new EvidenceStore tests (`firmware/test_app/main/test_evidence_store.cpp`).
- Total firmware test count: 176 (168 + 8).
- **CI Enhancement**:
- Added `backend-test` job to GitHub Actions pipeline (7-job workflow).

### Changed
- Backend API version bumped to `3.2.0`.
- Frontend `api.js` extended with meter CRUD and CSV export functions.
- Frontend `style.css` extended with modal, status indicator, and form styles.

=======
>>>>>>> 469b660da70c38354fe5127353f451559b605a7f
## [3.1.0] - 2026-03-06

### Added
Expand Down
Loading
Loading