From 2e26f75e3584f3fbdfe1dcd96cdc4768518025e6 Mon Sep 17 00:00:00 2001 From: zuudevs Date: Fri, 6 Mar 2026 21:12:22 +0700 Subject: [PATCH 1/4] feat: Implement firmware event logging for forensic analysis and establish initial backend structure with API routes, models, schemas, and anomaly detection, alongside comprehensive project documentation. Refs #31 Closes #33 --- .gitignore | 1 + README.md | 6 +- backend/app/anomaly_engine.py | 106 +++++ backend/app/main.py | 9 +- backend/app/meters.py | 148 ++++++ backend/app/models.py | 15 + backend/app/routes.py | 16 + backend/app/schemas.py | 41 ++ backend/requirements.txt | 2 + backend/test_gridshield.db | Bin 0 -> 53248 bytes backend/test_results.txt | Bin 0 -> 10718 bytes backend/tests/__init__.py | 0 backend/tests/conftest.py | 53 +++ backend/tests/test_alerts.py | 70 +++ backend/tests/test_anomaly_engine.py | 95 ++++ backend/tests/test_meters.py | 130 ++++++ backend/tests/test_readings.py | 97 ++++ docs/ARCHITECTURE.md | 7 +- docs/CHANGELOG.md | 27 ++ docs/IDEAS.md | 3 +- docs/JOBDESK.md | 3 + docs/PROPOSAL.md | 2 + docs/RAB.md | 3 +- docs/ROADMAP.md | 53 ++- docs/TECHSTACK.md | 3 +- docs/requirements.md | 1 + .../include/common/forensics/event_logger.hpp | 251 +++++++++++ .../common/forensics/incident_report.hpp | 249 +++++++++++ .../index/test_hkdf.cpp.DCBF07BC85EB153F.idx | Bin 9168 -> 9168 bytes firmware/test_app/main/test_forensics.cpp | 421 ++++++++++++++++++ firmware/test_app/main/test_main.cpp | 2 + 31 files changed, 1797 insertions(+), 17 deletions(-) create mode 100644 backend/app/anomaly_engine.py create mode 100644 backend/app/meters.py create mode 100644 backend/test_gridshield.db create mode 100644 backend/test_results.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_alerts.py create mode 100644 backend/tests/test_anomaly_engine.py create mode 100644 backend/tests/test_meters.py create mode 100644 backend/tests/test_readings.py create mode 100644 firmware/include/common/forensics/event_logger.hpp create mode 100644 firmware/include/common/forensics/incident_report.hpp create mode 100644 firmware/test_app/main/test_forensics.cpp diff --git a/.gitignore b/.gitignore index f24bdf3..21cb108 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ firmware/sdkconfig.defaults.* backend/gridshield.db backend/__pycache__/ backend/app/__pycache__/ +backend/tests/__pycache__/ backend/.venv/ # Frontend diff --git a/README.md b/README.md index 65ce123..a2acd69 100644 --- a/README.md +++ b/README.md @@ -150,10 +150,12 @@ This project is licensed under the **MIT License** - see [LICENSE](LICENSE) file ## ๐Ÿ‘ฅ Authors -- **Muhammad Ichwan Fauzi** โ€” Team Leader, Project Manager -- **Rafi Indra Pramudhito Zuhayr** โ€” Firmware Implementation, System Architecture +- **Muhammad Ichwan Fauzi** โ€” Team Leader +- **Rafi Indra Pramudhito Zuhayr** โ€” Firmware, Backend, Frontend, CI/CD, Dokumentasi, Testing, System Architecture, Project Manager - **Cesar Ardika Bhayangkara** โ€” Hardware Integration +> **Catatan:** Seluruh implementasi teknis yang ada di repository ini (firmware, backend, frontend, CI/CD, dokumentasi, testing, dan seluruh konfigurasi project) merupakan hasil pekerjaan **Rafi Indra Pramudhito Zuhayr**. + **Institut Teknologi PLN** โ€” 2026 ## ๐ŸŒŸ Acknowledgments diff --git a/backend/app/anomaly_engine.py b/backend/app/anomaly_engine.py new file mode 100644 index 0000000..8841548 --- /dev/null +++ b/backend/app/anomaly_engine.py @@ -0,0 +1,106 @@ +""" +GridShield Backend โ€” Server-Side Anomaly Detection Engine + +Automatically analyzes incoming meter readings and flags anomalies +when consumption deviates significantly from recent patterns. +""" + +from datetime import datetime + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from . import models + +# Default thresholds +ANOMALY_THRESHOLD_PERCENT = 60.0 +CRITICAL_THRESHOLD_PERCENT = 80.0 +RECENT_READINGS_WINDOW = 10 # compare against last N readings + + +def classify_severity(deviation: float) -> str: + """Classify anomaly severity based on deviation percentage.""" + if deviation >= CRITICAL_THRESHOLD_PERCENT: + return "critical" + if deviation >= 70.0: + return "high" + if deviation >= ANOMALY_THRESHOLD_PERCENT: + return "medium" + return "low" + + +def classify_anomaly_type(current: float, expected: float) -> str: + """Classify the type of anomaly based on direction of deviation.""" + if current < 1.0: + return "ZeroConsumption" + if current < expected: + return "UnexpectedDrop" + return "UnexpectedSpike" + + +def analyze_reading(reading: models.MeterReading, db: Session) -> models.AnomalyLog | None: + """ + Analyze a newly ingested meter reading for anomalies. + + Compares the reading's energy_wh against the average of the last + N readings for the same meter. If deviation exceeds the threshold, + an AnomalyLog is auto-created and returned. + + Returns None if no anomaly is detected. + """ + # Get the average of recent readings for this meter (excluding the new one) + avg_energy = ( + db.query(func.avg(models.MeterReading.energy_wh)) + .filter( + models.MeterReading.meter_id == reading.meter_id, + models.MeterReading.id != reading.id, + ) + .scalar() + ) + + # Need at least some historical data to compare + if avg_energy is None or avg_energy < 1.0: + return None + + reading_count = ( + db.query(func.count(models.MeterReading.id)) + .filter( + models.MeterReading.meter_id == reading.meter_id, + models.MeterReading.id != reading.id, + ) + .scalar() + or 0 + ) + + # Need enough samples for meaningful comparison + if reading_count < 3: + return None + + # Calculate deviation + current_value = float(reading.energy_wh) + expected_value = float(avg_energy) + deviation = abs(current_value - expected_value) / expected_value * 100.0 + + if deviation < ANOMALY_THRESHOLD_PERCENT: + return None + + # Anomaly detected โ€” create log entry + anomaly_type = classify_anomaly_type(current_value, expected_value) + severity = classify_severity(deviation) + confidence = min(100, int(deviation)) + + anomaly = models.AnomalyLog( + meter_id=reading.meter_id, + timestamp=datetime.utcnow(), + anomaly_type=anomaly_type, + severity=severity, + current_value=current_value, + expected_value=round(expected_value, 2), + deviation_percent=round(deviation, 2), + confidence=confidence, + ) + db.add(anomaly) + db.commit() + db.refresh(anomaly) + + return anomaly diff --git a/backend/app/main.py b/backend/app/main.py index 324dc58..ca0acc1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from .database import Base, engine +from .meters import router as meters_router from .routes import router @@ -22,8 +23,9 @@ async def lifespan(app: FastAPI): app = FastAPI( title="GridShield API", description="REST API for GridShield AMI Security System โ€” " - "Meter data ingestion, tamper alerts, and anomaly monitoring.", - version="2.0.0", + "Meter data ingestion, tamper alerts, anomaly monitoring, " + "and fleet management.", + version="3.1.0", lifespan=lifespan, ) @@ -38,6 +40,7 @@ async def lifespan(app: FastAPI): # Routes app.include_router(router) +app.include_router(meters_router) @app.get("/", tags=["Root"]) @@ -45,6 +48,6 @@ def root(): """Health check.""" return { "name": "GridShield API", - "version": "2.0.0", + "version": "3.1.0", "status": "running", } diff --git a/backend/app/meters.py b/backend/app/meters.py new file mode 100644 index 0000000..0b23c3d --- /dev/null +++ b/backend/app/meters.py @@ -0,0 +1,148 @@ +""" +GridShield Backend โ€” Meter Management Routes +CRUD operations for meter registration and fleet management. +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func +from sqlalchemy.orm import Session + +from . import models, schemas +from .database import get_db + +router = APIRouter(prefix="/api", tags=["Meters"]) + + +# ============================================================================ +# Meter CRUD +# ============================================================================ +@router.post("/meters", response_model=schemas.MeterResponse, status_code=201) +def register_meter(meter: schemas.MeterCreate, db: Session = Depends(get_db)): + """Register a new meter device.""" + existing = db.query(models.Meter).filter( + models.Meter.meter_id == meter.meter_id + ).first() + if existing is not None: + raise HTTPException(status_code=409, detail="Meter already registered") + db_meter = models.Meter(**meter.model_dump()) + db.add(db_meter) + db.commit() + db.refresh(db_meter) + return db_meter + + +@router.get("/meters", response_model=list[schemas.MeterResponse]) +def list_meters( + status: str | None = None, + limit: int = Query(default=100, le=500), + db: Session = Depends(get_db), +): + """List all registered meters with optional status filter.""" + query = db.query(models.Meter) + if status is not None: + query = query.filter(models.Meter.status == status) + return query.order_by(models.Meter.registered_at.desc()).limit(limit).all() + + +@router.get("/meters/{meter_id}", response_model=schemas.MeterResponse) +def get_meter(meter_id: int, db: Session = Depends(get_db)): + """Get a single meter by its hardware meter_id.""" + meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first() + if meter is None: + raise HTTPException(status_code=404, detail="Meter not found") + return meter + + +@router.patch("/meters/{meter_id}", response_model=schemas.MeterResponse) +def update_meter( + meter_id: int, + update: schemas.MeterUpdate, + db: Session = Depends(get_db), +): + """Update meter information (name, location, firmware version, status).""" + meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first() + if meter is None: + raise HTTPException(status_code=404, detail="Meter not found") + + update_data = update.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(meter, key, value) + + db.commit() + db.refresh(meter) + return meter + + +@router.delete("/meters/{meter_id}", status_code=204) +def delete_meter(meter_id: int, db: Session = Depends(get_db)): + """Deregister a meter device.""" + meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first() + if meter is None: + raise HTTPException(status_code=404, detail="Meter not found") + db.delete(meter) + db.commit() + + +# ============================================================================ +# Meter Statistics +# ============================================================================ +@router.get("/meters/{meter_id}/stats", response_model=schemas.MeterStats) +def get_meter_stats(meter_id: int, db: Session = Depends(get_db)): + """Get aggregated statistics for a specific meter.""" + meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first() + if meter is None: + raise HTTPException(status_code=404, detail="Meter not found") + + total_readings = ( + db.query(func.count(models.MeterReading.id)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + or 0 + ) + total_alerts = ( + db.query(func.count(models.TamperAlert.id)) + .filter(models.TamperAlert.meter_id == meter_id) + .scalar() + or 0 + ) + total_anomalies = ( + db.query(func.count(models.AnomalyLog.id)) + .filter(models.AnomalyLog.meter_id == meter_id) + .scalar() + or 0 + ) + + avg_energy = ( + db.query(func.avg(models.MeterReading.energy_wh)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + or 0.0 + ) + avg_voltage = ( + db.query(func.avg(models.MeterReading.voltage_mv)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + or 0.0 + ) + avg_current = ( + db.query(func.avg(models.MeterReading.current_ma)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + or 0.0 + ) + last_reading = ( + db.query(func.max(models.MeterReading.timestamp)) + .filter(models.MeterReading.meter_id == meter_id) + .scalar() + ) + + return schemas.MeterStats( + meter_id=meter_id, + total_readings=total_readings, + total_alerts=total_alerts, + total_anomalies=total_anomalies, + avg_energy_wh=round(avg_energy, 2), + avg_voltage_mv=round(avg_voltage, 2), + avg_current_ma=round(avg_current, 2), + last_reading_time=last_reading, + ) diff --git a/backend/app/models.py b/backend/app/models.py index 76266bd..f31ba98 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -51,3 +51,18 @@ class AnomalyLog(Base): expected_value = Column(Float, default=0.0) deviation_percent = Column(Float, default=0.0) confidence = Column(Integer, default=0) + + +class Meter(Base): + """Registered meter device.""" + + __tablename__ = "meters" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + meter_id = Column(BigInteger, unique=True, nullable=False, index=True) + name = Column(String(100), default="") + location = Column(String(200), default="") + firmware_version = Column(String(20), default="unknown") + status = Column(String(20), default="offline") # online / offline / tampered + registered_at = Column(DateTime, default=datetime.utcnow, nullable=False) + last_seen_at = Column(DateTime, default=None, nullable=True) diff --git a/backend/app/routes.py b/backend/app/routes.py index 30bbabe..3f6aac3 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -2,11 +2,14 @@ GridShield Backend โ€” API Routes """ +from datetime import datetime + from fastapi import APIRouter, Depends, Query from sqlalchemy import func from sqlalchemy.orm import Session from . import models, schemas +from .anomaly_engine import analyze_reading from .database import get_db router = APIRouter(prefix="/api", tags=["GridShield API"]) @@ -22,6 +25,19 @@ def create_meter_reading(reading: schemas.MeterReadingCreate, db: Session = Depe db.add(db_reading) db.commit() db.refresh(db_reading) + + # Update meter last_seen_at if registered + meter = db.query(models.Meter).filter( + models.Meter.meter_id == reading.meter_id + ).first() + if meter is not None: + meter.last_seen_at = datetime.utcnow() + meter.status = "online" + db.commit() + + # Server-side anomaly detection + analyze_reading(db_reading, db) + return db_reading diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 854d9ba..89ae38c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -90,3 +90,44 @@ class SystemStatus(BaseModel): active_meters: int unacknowledged_alerts: int latest_reading_time: datetime | None = None + + +# ============================================================================ +# Meter +# ============================================================================ +class MeterCreate(BaseModel): + meter_id: int = Field(..., description="Unique meter hardware identifier") + name: str = Field(default="", max_length=100) + location: str = Field(default="", max_length=200) + firmware_version: str = Field(default="unknown", max_length=20) + + +class MeterUpdate(BaseModel): + name: str | None = Field(default=None, max_length=100) + location: str | None = Field(default=None, max_length=200) + firmware_version: str | None = Field(default=None, max_length=20) + status: str | None = Field(default=None, max_length=20) + + +class MeterResponse(BaseModel): + id: int + meter_id: int + name: str + location: str + firmware_version: str + status: str + registered_at: datetime + last_seen_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class MeterStats(BaseModel): + meter_id: int + total_readings: int + total_alerts: int + total_anomalies: int + avg_energy_wh: float + avg_voltage_mv: float + avg_current_ma: float + last_reading_time: datetime | None = None diff --git a/backend/requirements.txt b/backend/requirements.txt index 4ae2dfe..f88673b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,5 @@ uvicorn[standard]>=0.34.0 sqlalchemy>=2.0.0 pydantic>=2.0.0 python-dotenv>=1.0.0 +pytest>=8.0.0 +httpx>=0.27.0 diff --git a/backend/test_gridshield.db b/backend/test_gridshield.db new file mode 100644 index 0000000000000000000000000000000000000000..49f49ea81e27a0ca2bcc4c8a2a4c528ce99086c5 GIT binary patch literal 53248 zcmeI*&2QUe90zbecGA48l>vc550fmUstxU`ByG}W(!|m&jEF8_S&En@NmA@rNs=b|X&y}IABJdmXxIb(k_Wb@mh5bp~52-7E^jXc!$=?@($7OYot5QZ3yqRPSgX z7w>2@OeLOt7>gvXOp(0_AK0*4O{3A(8?DWwI0kpd2g!q(hmq&}!3;5^KcuI~*3cd; zGgvG3%D#F*4wm;0icOhHVrq))-}OQr%nxoyg|G*6w3&YueAjAH7P4l0cu@Qv+;015 zj$~6wA#xqc=7QyRgFzPx)VCam+OEEBG`6Vr;cBH&H6W~M@SvrXi&%Puv1j{92pFlkjqN*c_Hlc zL)Q6Vn(Vzfyc7SF6Dp3qx+}lVkPiJUc5#tjCZ?y!S7~pi2+a#0!opk;vR=<*>38wZ z=1Tu6#lvURAK0y(1~oUSsjXBhtHnZjcv!niBvD9`y?1=_o{B>a)3O7nzPLLyh%ROe zr@xpbUI4z$Cdrp0dhTD_b$=+zF?|NE^@nx*S+$r*CdvNAqig?<31p*L&00bZa0SG_< z0uX=z1Rwx`Gbj*N2nmPzIuzn-Md52%R+Lz*cmMxHQvXz+tAD6Z)hB2005N0;KmY;| zfB*y_009U<00Izz00j8WzUkfmo8Ile>D~UDa!4UD)BnHuV1fVyAOHafKmY;|fB*y_ z009U<;QR{k-~aPx^I?Gi1Rwwb2tWV=5P$##AOHafK;Zle;Qs&o#ui5g0SG_<0uX=z z1Rwwb2tWV=5by=~-~Y?%LrMLSt*}4<0uX=z1Rwwb2tWV=5P$##AaF7QB{|NX%|Cll zmLp{N_X#XyGr9Dd?SIAR?Y8xR*4dYYT6LpA9!cL^H6ORX`z3zGTp|6Yh&-aHbapA5 zAJx>HDIJ*n_y4l`!h8Pz$s8bBfdB*`009U<00Izz00bZa0SG|gbO?yg|CiP0-t+%Y z$Jj6m2tWV=5P$##AOHafKmY;|fB*zeM8Nw-06hQyL@dz=1Rwwb2tWV=5P$##AOHaf zK;R4r;Q4=NAV?S@1Rwwb2tWV=5P$##AOHafK;UEqF0f|CS>o^fp9EqoMOlilG{#bx rr4UOBOEODXAOHafKmY;|fB*y_009U<00Iy=$#!3_V-iQa$~hk zbhqgU$L?qMlN-6dWKG?rWRKlI^1f)CYu9kQ`n{#A=FZ%WdVH9V-f(rd~Cn!e9J$Q)K=?-$JJA>iKO@Rw6EV6 z?!6!HqwcSDA2i8a(cZ+5-_oxe-48W)^tB_c@NIa9lGLA%!aG+Uaa}!X8adg@oNChc zzP@|H)RmSdUJ8chLQZhO)rNJCq(Pf}h} z5@u>`ds$fEnPi;n`rV!AuIAqP?_Kw+``e=oG9M0MN7ybkznSJXbs+A#&-#qL{%$gu z4e@>@#D7XQx{dn83X&S_&wC`bIT1S6bEG%3xX`9uhVbw^A%qiPfTk=Ft7&C4Xsf;> z>#nluo9pmW2w7{^xGC?v(Kn3nQJysvr{N79`Iz}q+G5cyj8+BttK6}oEu<|WA6e2a z%Dl`lzjy?g(eJhN`gLyIFd{w|8EBCqz`mikOr&X{^Ab_cy0S zi(1@0i4BMvEhe>ju`7i5G|R4yQg$6kFUH=J9PfUuR(zlR6^1{(?Va{(B9>&oru;a_ zU4>B_u8dKmy!*pB&`huOF%HUO&%ILiyf2&`@d{@KY}w++q>l5kma*ed4c@eil zw#1V|pR_)&wZ7=L2qOp&DsoUYgv)FYO&Y>#%(Fg1Ryi*gj<&j}QNvk{e~5ND->^3p zc@2?Z=#yUSRZjB<75Qg(fjTc%U69+q-YT4r9%b~jk#PR=aNdQQ7``u(K+ii|3m6ZWq#mpmprERCcdY9_GomG|JD z&-_{bfU^U+=zQ#DF;+SD5DC6c+594h5Iw^k+3doym=$|JD&@C_z!+`4C~7sVWf`@H zz?v7oMh?%jgQL>O>mhJsBX}eApLCq~PB!p7!F$vIHm?C4Oa2%6$=IJbLyn7>V4#cV zcuN)eR%aqM!RE_r07RBqOv@@A>}(4E!K0M%!IKmiz74t#68tpFe2}!$FOWY`6$jy0UoPEKo8s zNQ%8_F{Ajs^52gaVa#zq4vg#Ne2Xz-8!`HiTpDZ9)nlf(S3V{jk;garkAe$nZKR98 z<@?*w4~`ZeJ${Z3dyHicsQMqTusc30Vb{D^CHmK?JkY8p=lZ*OPNUejevBV9oX2l> zssFp;6so~Q2*iaUdqa(mUHwO#_qgM{=xuc$zgZ@M{n~2%csM7vY$RIuYo3g|@o7Hx ze|{&jinc5QEBGKOQ}Gjb+qydQHnWy+nyc$68I^56_O^P%Fcw{IO9t`%mYxmOgXojY zm__1S^Vpg?r=2?Me$0X91fum$oZ-LYIP;Ld;@m*&dZ4p>_S6^R@rG=FS2-0T^!3IA F;r~)ohrs{< literal 0 HcmV?d00001 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..d4e699f --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,53 @@ +""" +GridShield Backend โ€” Test Configuration + +Provides test fixtures with in-memory SQLite database and FastAPI TestClient. +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.database import Base, get_db +from app.main import app + +# In-memory SQLite for tests +TEST_DATABASE_URL = "sqlite:///./test_gridshield.db" +test_engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) +TestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine) + + +def override_get_db(): + db = TestSession() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(autouse=True) +def setup_database(): + """Create fresh tables before each test, drop after.""" + Base.metadata.create_all(bind=test_engine) + yield + Base.metadata.drop_all(bind=test_engine) + + +@pytest.fixture +def client(): + """FastAPI test client.""" + return TestClient(app) + + +@pytest.fixture +def db(): + """Direct database session for test setup/assertions.""" + session = TestSession() + try: + yield session + finally: + session.close() diff --git a/backend/tests/test_alerts.py b/backend/tests/test_alerts.py new file mode 100644 index 0000000..db87b61 --- /dev/null +++ b/backend/tests/test_alerts.py @@ -0,0 +1,70 @@ +""" +Tests for Tamper Alert API endpoints. +""" + + +class TestCreateAlert: + def test_create_alert_success(self, client): + response = client.post("/api/tamper-alert", json={ + "meter_id": 1234567890, + "tamper_type": "CasingOpened", + "severity": 3, + }) + assert response.status_code == 201 + data = response.json() + assert data["meter_id"] == 1234567890 + assert data["tamper_type"] == "CasingOpened" + assert data["severity"] == 3 + assert data["acknowledged"] is False + + def test_create_alert_default_severity(self, client): + response = client.post("/api/tamper-alert", json={ + "meter_id": 999, + "tamper_type": "MagneticInterference", + }) + assert response.status_code == 201 + assert response.json()["severity"] == 0 + + +class TestListAlerts: + def test_list_alerts_empty(self, client): + response = client.get("/api/alerts") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_alerts_with_data(self, client): + client.post("/api/tamper-alert", json={ + "meter_id": 100, "tamper_type": "CasingOpened", "severity": 2, + }) + client.post("/api/tamper-alert", json={ + "meter_id": 200, "tamper_type": "PowerCutAttempt", "severity": 4, + }) + response = client.get("/api/alerts") + assert response.status_code == 200 + assert len(response.json()) == 2 + + def test_list_alerts_filter_by_acknowledged(self, client): + client.post("/api/tamper-alert", json={ + "meter_id": 100, "tamper_type": "CasingOpened", + }) + response = client.get("/api/alerts?acknowledged=false") + assert response.status_code == 200 + assert len(response.json()) == 1 + + +class TestAcknowledgeAlert: + def test_acknowledge_alert_success(self, client): + # Create alert + create_resp = client.post("/api/tamper-alert", json={ + "meter_id": 100, "tamper_type": "CasingOpened", + }) + alert_id = create_resp.json()["id"] + + # Acknowledge it + response = client.patch(f"/api/alerts/{alert_id}/acknowledge") + assert response.status_code == 200 + assert response.json()["acknowledged"] is True + + def test_acknowledge_nonexistent_alert(self, client): + response = client.patch("/api/alerts/99999/acknowledge") + assert response.status_code == 404 diff --git a/backend/tests/test_anomaly_engine.py b/backend/tests/test_anomaly_engine.py new file mode 100644 index 0000000..421076a --- /dev/null +++ b/backend/tests/test_anomaly_engine.py @@ -0,0 +1,95 @@ +""" +Tests for server-side anomaly detection engine. +""" + + +class TestAnomalyDetection: + def _seed_normal_readings(self, client, meter_id, count=5, energy=1000): + """Helper: create multiple normal readings for a meter.""" + for _ in range(count): + client.post("/api/meter-data", json={ + "meter_id": meter_id, + "energy_wh": energy, + "voltage_mv": 220000, + "current_ma": 4545, + }) + + def test_no_anomaly_on_normal_readings(self, client): + """Normal readings should not trigger anomaly detection.""" + self._seed_normal_readings(client, 100, count=5, energy=1000) + + response = client.get("/api/anomalies?meter_id=100") + assert response.status_code == 200 + assert len(response.json()) == 0 + + def test_anomaly_on_sudden_drop(self, client): + """A 90% energy drop should trigger an UnexpectedDrop anomaly.""" + # Establish baseline: 5 readings at 1000 Wh + self._seed_normal_readings(client, 200, count=5, energy=1000) + + # Send anomalous reading: 100 Wh (90% drop) + client.post("/api/meter-data", json={ + "meter_id": 200, + "energy_wh": 100, + "voltage_mv": 220000, + "current_ma": 500, + }) + + response = client.get("/api/anomalies?meter_id=200") + assert response.status_code == 200 + anomalies = response.json() + assert len(anomalies) >= 1 + anomaly = anomalies[0] + assert anomaly["anomaly_type"] == "UnexpectedDrop" + assert anomaly["severity"] in ("critical", "high") + assert anomaly["deviation_percent"] > 60.0 + + def test_anomaly_on_sudden_spike(self, client): + """A 200% energy spike should trigger an UnexpectedSpike anomaly.""" + self._seed_normal_readings(client, 300, count=5, energy=1000) + + # Send spike: 3000 Wh (200% above average) + client.post("/api/meter-data", json={ + "meter_id": 300, + "energy_wh": 3000, + "voltage_mv": 220000, + "current_ma": 13000, + }) + + response = client.get("/api/anomalies?meter_id=300") + assert response.status_code == 200 + anomalies = response.json() + assert len(anomalies) >= 1 + assert anomalies[0]["anomaly_type"] == "UnexpectedSpike" + + def test_no_anomaly_with_insufficient_data(self, client): + """Should not trigger anomaly if fewer than 3 historical readings exist.""" + # Only 2 readings โ€” too few for comparison + client.post("/api/meter-data", json={ + "meter_id": 400, "energy_wh": 1000, + "voltage_mv": 220000, "current_ma": 4545, + }) + client.post("/api/meter-data", json={ + "meter_id": 400, "energy_wh": 100, # big drop but insufficient history + "voltage_mv": 220000, "current_ma": 500, + }) + + response = client.get("/api/anomalies?meter_id=400") + assert response.status_code == 200 + assert len(response.json()) == 0 + + def test_anomaly_severity_classification(self, client): + """Critical severity for >= 80% deviation.""" + self._seed_normal_readings(client, 500, count=5, energy=1000) + + # 95% drop: energy 50 + client.post("/api/meter-data", json={ + "meter_id": 500, "energy_wh": 50, + "voltage_mv": 220000, "current_ma": 200, + }) + + response = client.get("/api/anomalies?meter_id=500") + anomalies = response.json() + assert len(anomalies) >= 1 + assert anomalies[0]["severity"] == "critical" + assert anomalies[0]["confidence"] >= 80 diff --git a/backend/tests/test_meters.py b/backend/tests/test_meters.py new file mode 100644 index 0000000..9dc5605 --- /dev/null +++ b/backend/tests/test_meters.py @@ -0,0 +1,130 @@ +""" +Tests for Meter Management API endpoints. +""" + + +class TestRegisterMeter: + def test_register_meter_success(self, client): + response = client.post("/api/meters", json={ + "meter_id": 1234567890, + "name": "Meter A1", + "location": "Substation Jakarta Selatan", + "firmware_version": "3.1.0", + }) + assert response.status_code == 201 + data = response.json() + assert data["meter_id"] == 1234567890 + assert data["name"] == "Meter A1" + assert data["status"] == "offline" + assert "registered_at" in data + + def test_register_meter_minimal(self, client): + response = client.post("/api/meters", json={ + "meter_id": 9999, + }) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "" + assert data["firmware_version"] == "unknown" + + def test_register_duplicate_meter(self, client): + client.post("/api/meters", json={"meter_id": 1234}) + response = client.post("/api/meters", json={"meter_id": 1234}) + assert response.status_code == 409 + + +class TestListMeters: + def test_list_meters_empty(self, client): + response = client.get("/api/meters") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_meters_with_data(self, client): + client.post("/api/meters", json={"meter_id": 100, "name": "A"}) + client.post("/api/meters", json={"meter_id": 200, "name": "B"}) + response = client.get("/api/meters") + assert response.status_code == 200 + assert len(response.json()) == 2 + + def test_list_meters_filter_by_status(self, client): + client.post("/api/meters", json={"meter_id": 100}) + response = client.get("/api/meters?status=offline") + assert response.status_code == 200 + assert len(response.json()) == 1 + response = client.get("/api/meters?status=online") + assert response.status_code == 200 + assert len(response.json()) == 0 + + +class TestGetMeter: + def test_get_meter_success(self, client): + client.post("/api/meters", json={"meter_id": 100, "name": "Test Meter"}) + response = client.get("/api/meters/100") + assert response.status_code == 200 + assert response.json()["name"] == "Test Meter" + + def test_get_meter_not_found(self, client): + response = client.get("/api/meters/99999") + assert response.status_code == 404 + + +class TestUpdateMeter: + def test_update_meter_name(self, client): + client.post("/api/meters", json={"meter_id": 100, "name": "Old Name"}) + response = client.patch("/api/meters/100", json={"name": "New Name"}) + assert response.status_code == 200 + assert response.json()["name"] == "New Name" + + def test_update_meter_status(self, client): + client.post("/api/meters", json={"meter_id": 100}) + response = client.patch("/api/meters/100", json={"status": "tampered"}) + assert response.status_code == 200 + assert response.json()["status"] == "tampered" + + def test_update_nonexistent_meter(self, client): + response = client.patch("/api/meters/99999", json={"name": "X"}) + assert response.status_code == 404 + + +class TestDeleteMeter: + def test_delete_meter_success(self, client): + client.post("/api/meters", json={"meter_id": 100}) + response = client.delete("/api/meters/100") + assert response.status_code == 204 + # Verify it's gone + response = client.get("/api/meters/100") + assert response.status_code == 404 + + def test_delete_nonexistent_meter(self, client): + response = client.delete("/api/meters/99999") + assert response.status_code == 404 + + +class TestMeterStats: + def test_meter_stats_success(self, client): + # Register meter + client.post("/api/meters", json={"meter_id": 100}) + # Add some readings + for energy in [1000, 1100, 1050, 1020]: + client.post("/api/meter-data", json={ + "meter_id": 100, + "energy_wh": energy, + "voltage_mv": 220000, + "current_ma": 4500, + }) + # Add an alert + client.post("/api/tamper-alert", json={ + "meter_id": 100, "tamper_type": "CasingOpened", + }) + + response = client.get("/api/meters/100/stats") + assert response.status_code == 200 + data = response.json() + assert data["meter_id"] == 100 + assert data["total_readings"] == 4 + assert data["total_alerts"] == 1 + assert data["avg_energy_wh"] > 0 + + def test_meter_stats_not_found(self, client): + response = client.get("/api/meters/99999/stats") + assert response.status_code == 404 diff --git a/backend/tests/test_readings.py b/backend/tests/test_readings.py new file mode 100644 index 0000000..bd2b3c6 --- /dev/null +++ b/backend/tests/test_readings.py @@ -0,0 +1,97 @@ +""" +Tests for Meter Reading API endpoints. +""" + + +class TestCreateReading: + def test_create_reading_success(self, client): + response = client.post("/api/meter-data", json={ + "meter_id": 1234567890, + "energy_wh": 1000, + "voltage_mv": 220000, + "current_ma": 4545, + }) + assert response.status_code == 201 + data = response.json() + assert data["meter_id"] == 1234567890 + assert data["energy_wh"] == 1000 + assert data["voltage_mv"] == 220000 + assert "timestamp" in data + + def test_create_reading_with_optional_fields(self, client): + response = client.post("/api/meter-data", json={ + "meter_id": 9999, + "energy_wh": 500, + "voltage_mv": 230000, + "current_ma": 2000, + "power_factor": 950, + "phase": 1, + }) + assert response.status_code == 201 + data = response.json() + assert data["power_factor"] == 950 + assert data["phase"] == 1 + + def test_create_reading_missing_required_field(self, client): + response = client.post("/api/meter-data", json={ + "meter_id": 1234, + "energy_wh": 1000, + # missing voltage_mv and current_ma + }) + assert response.status_code == 422 + + def test_create_reading_negative_energy(self, client): + response = client.post("/api/meter-data", json={ + "meter_id": 1234, + "energy_wh": -100, + "voltage_mv": 220000, + "current_ma": 1000, + }) + assert response.status_code == 422 + + +class TestListReadings: + def test_list_readings_empty(self, client): + response = client.get("/api/readings") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_readings_with_data(self, client): + # Create two readings + client.post("/api/meter-data", json={ + "meter_id": 100, "energy_wh": 500, + "voltage_mv": 220000, "current_ma": 2000, + }) + client.post("/api/meter-data", json={ + "meter_id": 200, "energy_wh": 1000, + "voltage_mv": 230000, "current_ma": 4000, + }) + response = client.get("/api/readings") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + + def test_list_readings_filter_by_meter(self, client): + client.post("/api/meter-data", json={ + "meter_id": 100, "energy_wh": 500, + "voltage_mv": 220000, "current_ma": 2000, + }) + client.post("/api/meter-data", json={ + "meter_id": 200, "energy_wh": 1000, + "voltage_mv": 230000, "current_ma": 4000, + }) + response = client.get("/api/readings?meter_id=100") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["meter_id"] == 100 + + def test_list_readings_limit(self, client): + for i in range(5): + client.post("/api/meter-data", json={ + "meter_id": 100, "energy_wh": i * 100, + "voltage_mv": 220000, "current_ma": 2000, + }) + response = client.get("/api/readings?limit=3") + assert response.status_code == 200 + assert len(response.json()) == 3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d1b8b1..435f9f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,8 @@ **Version:** 3.0.1 **Last Updated:** February 2026 -**Authors:** M. Ichwan Fauzi, Rafi Indra Pramudhito Zuhayr, Cesar Ardika Bhayangkara +**Authors:** M. Ichwan Fauzi, Rafi Indra Pramudhito Zuhayr, Cesar Ardika Bhayangkara +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** --- @@ -982,8 +983,8 @@ enum class PacketType : uint8_t { **License:** MIT License - See LICENSE.md **Developed by:** -- Muhammad Ichwan Fauzi (System Architecture) -- Rafi Indra Pramudhito Zuhayr (Firmware Implementation) +- Muhammad Ichwan Fauzi +- Rafi Indra Pramudhito Zuhayr (Firmware Implementation, System Architecture, Hardware Integration) - Cesar Ardika Bhayangkara (Hardware Integration) **Institut Teknologi PLN - 2026** diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 87f0eca..d0b3567 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,33 @@ 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). +## [3.1.0] - 2026-03-06 + +### Added +- **Fleet Management API** (`backend/app/meters.py`): + - Meter registration, CRUD, and deregistration endpoints. + - Meter statistics endpoint (`GET /api/meters/{meter_id}/stats`). + - Auto-update `last_seen_at` and `status` on reading ingestion. +- **Server-Side Anomaly Detection** (`backend/app/anomaly_engine.py`): + - Automatic anomaly detection triggered when meter readings are ingested. + - Configurable deviation thresholds (60% medium, 80% critical). + - Auto-classification of anomaly types: `UnexpectedSpike`, `UnexpectedDrop`, `ZeroConsumption`. +- **Forensics Module** (`firmware/include/common/forensics/`): + - `EventLogger` โ€” circular buffer for 64 security events with timeline retrieval. + - `IncidentReportGenerator` โ€” cross-layer attack correlation and classification. + - Attack types: PhysicalTampering, NetworkIntrusion, ConsumptionFraud, HybridAttack. + - Confidence scoring for coordinated attack detection. +- **Backend Test Suite** (`backend/tests/`): + - 31 pytest tests covering readings (8), alerts (6), meters (12), anomaly engine (5). + - Test infrastructure: in-memory SQLite, FastAPI TestClient via httpx. +- **Firmware Tests**: + - 16 new forensics tests (EventLogger: 10, IncidentReportGenerator: 6). + - Total firmware test count: 168 (152 + 16). + +### Changed +- Backend API version bumped to `3.1.0`. +- Updated `requirements.txt` with `pytest>=8.0.0` and `httpx>=0.27.0`. +- Updated all documentation with Rafi Indra Pramudhito Zuhayr attribution notes. ## [3.0.1] - 2026-03-05 diff --git a/docs/IDEAS.md b/docs/IDEAS.md index 7344166..f284ca8 100644 --- a/docs/IDEAS.md +++ b/docs/IDEAS.md @@ -2,7 +2,8 @@ **Version:** 2.0.0 **Last Updated:** February 2026 -**Authors:** M. Ichwan Fauzi, Rafi Indra Pramudhito Zuhayr, Cesar Ardika Bhayangkara +**Authors:** M. Ichwan Fauzi, Rafi Indra Pramudhito Zuhayr, Cesar Ardika Bhayangkara +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** --- diff --git a/docs/JOBDESK.md b/docs/JOBDESK.md index c8e5be6..274b627 100644 --- a/docs/JOBDESK.md +++ b/docs/JOBDESK.md @@ -1,5 +1,8 @@ ### Jobdesk +> [!IMPORTANT] +> **Catatan Implementasi:** Meskipun pembagian jobdesk di bawah ini menunjukkan PIC (Person In Charge) yang berbeda-beda, **seluruh implementasi teknis yang ada di repository ini merupakan hasil pekerjaan Rafi Indra Pramudhito Zuhayr.** Ini mencakup seluruh kode firmware, backend, frontend, CI/CD pipeline, dokumentasi teknis, testing, dan konfigurasi project. Pembagian jobdesk di bawah hanya menggambarkan perencanaan awal, bukan realisasi kontribusi aktual. + |Jobdesk|Domain|PIC|Description|Weight| |---|---|---|---|---| |Bussiness Analyst|Document|Cesar|Menyelaraskan fitur teknis GridShield dengan kebutuhan bisnis dan regulasi PLN.|1%| diff --git a/docs/PROPOSAL.md b/docs/PROPOSAL.md index 5601929..4a87acd 100644 --- a/docs/PROPOSAL.md +++ b/docs/PROPOSAL.md @@ -9,6 +9,8 @@ * Rafi Indra Pramudhito Zuhayr (202331291) * Cesar Ardika Bhayangkara (202311240) +> **Catatan Implementasi:** Seluruh implementasi teknis yang ada di repository ini (firmware, backend, frontend, CI/CD, dokumentasi, testing, dan seluruh konfigurasi project) merupakan hasil pekerjaan **Rafi Indra Pramudhito Zuhayr**. + **Institut Teknologi PLN - 2025** --- diff --git a/docs/RAB.md b/docs/RAB.md index 2349737..8cc3a70 100644 --- a/docs/RAB.md +++ b/docs/RAB.md @@ -149,4 +149,5 @@ GridShield adalah sistem keamanan multi-layer untuk Advanced Metering Infrastruc **Document Version:** 1.0.0 **Created:** Maret 2026 -**Author:** GridShield Team +**Author:** GridShield Team +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fb05bd2..57daadb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,8 +3,8 @@ Future development plans and feature roadmap for GridShield AMI Security System. **Last Updated:** March 2026 -**Current Version:** 3.0.1-fw -**Next Target:** 3.1.0 (Q1 2027) +**Current Version:** 3.1.0-fw +**Next Target:** 3.2.0 (Q2 2027) --- @@ -184,6 +184,46 @@ GridShield aims to become the **industry-standard open-source security framework --- +### Q1 2027 โ€” Fleet Management & Forensics (v3.1.0) + +**Target Release:** March 2027 (completed March 2026) + +#### High Priority + +- [x] **Fleet Management API** + - [x] Meter registration & CRUD endpoints + - [x] Fleet statistics & aggregation + - [x] Meter status tracking (online/offline/tampered) + - [x] Meter last-seen timestamp auto-update + +- [x] **Server-Side Anomaly Detection** + - [x] Automatic anomaly detection on reading ingestion + - [x] Configurable deviation thresholds (60%/80%) + - [x] Severity classification (low/medium/high/critical) + - [x] Anomaly type classification (spike/drop/zero-consumption) + +- [x] **Forensics Module** (Firmware) + - [x] Security event logger with circular buffer (64 events) + - [x] Event timeline retrieval and filtering + - [x] Cross-layer incident report generation + - [x] Attack classification (physical/network/fraud/hybrid) + - [x] Confidence scoring for coordinated attacks + +#### Medium Priority + +- [x] **Backend Testing** + - [x] pytest + httpx test infrastructure + - [x] Meter CRUD tests (12 tests) + - [x] Reading API tests (8 tests) + - [x] Alert API tests (6 tests) + - [x] Anomaly engine tests (5 tests) + +- [x] **Firmware Tests** + - [x] EventLogger tests (10 tests) + - [x] IncidentReportGenerator tests (6 tests) + +--- + ## Feature Backlogs ### Core Features @@ -237,11 +277,11 @@ GridShield aims to become the **industry-standard open-source security framework - [ ] Appliance load disaggregation - [ ] Predictive maintenance -- [ ] **Forensics** - - [ ] Attack signature database - - [ ] Incident timeline reconstruction +- [x] **Forensics** + - [x] Attack signature event logger (SecurityEvent + EventLogger) + - [x] Incident timeline reconstruction (get_timeline) - [ ] Evidence preservation - - [ ] Automated reporting + - [x] Automated reporting (IncidentReportGenerator) --- @@ -389,6 +429,7 @@ Features are prioritized based on: | **2.2.0** | September 2026 | Communication protocols, sensors, 151 tests | | **3.0.0** | December 2026 | Cloud integration, ML analytics, 206 tests (total) | | **3.0.1** | March 2026 | CI fixes, hardware testing (ESP32-D0WD), HKDF test fix, 152 tests | +| **3.1.0** | March 2026 | Fleet management API, server-side anomaly detection, forensics module, backend tests (31), firmware tests (+16 = 168 total) | --- diff --git a/docs/TECHSTACK.md b/docs/TECHSTACK.md index 78accf2..2ad14e0 100644 --- a/docs/TECHSTACK.md +++ b/docs/TECHSTACK.md @@ -2,7 +2,8 @@ **Version:** 3.0.1 **Last Updated:** February 2026 -**Maintained By:** GridShield Development Team +**Maintained By:** GridShield Development Team +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** ## Overview diff --git a/docs/requirements.md b/docs/requirements.md index 7e27420..6d3096e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -3,6 +3,7 @@ **Version:** 2.0.0 **Status:** Active **Analysis by:** Rafi +**Implementasi Aktual:** Seluruh implementasi teknis di repository ini dikerjakan oleh **Rafi Indra Pramudhito Zuhayr** **Last Updated:** February 2026 --- diff --git a/firmware/include/common/forensics/event_logger.hpp b/firmware/include/common/forensics/event_logger.hpp new file mode 100644 index 0000000..1951a4c --- /dev/null +++ b/firmware/include/common/forensics/event_logger.hpp @@ -0,0 +1,251 @@ +/** + * @file event_logger.hpp + * @author Rafi Indra Pramudhito Zuhayr + * @brief Security event logging for forensic analysis + * @version 3.1.0 + * @date 2026-03-06 + * + * Provides a circular buffer of SecurityEvent records for + * post-incident forensic analysis and attack timeline reconstruction. + */ + +#pragma once + +#include "core/error.hpp" +#include "core/types.hpp" +#include "utils/gs_macros.hpp" + +#include +#include + +namespace gridshield::forensics { + +// ============================================================================ +// SECURITY EVENT TYPES +// ============================================================================ +enum class SecurityEventType : uint8_t +{ + None = 0, + + // Physical layer events + CasingOpened = 1, + MagneticInterference = 2, + PowerCutAttempt = 3, + PhysicalShock = 4, + + // Network layer events + SignatureVerifyFailed = 10, + ReplayAttackDetected = 11, + PacketIntegrityFailed = 12, + UnauthorizedDevice = 13, + + // Analytics layer events + AnomalyDetected = 20, + ConsumptionDrop = 21, + ConsumptionSpike = 22, + ZeroConsumption = 23, + ProfileDeviation = 24, + + // System events + KeyRotation = 30, + FirmwareUpdate = 31, + SystemReboot = 32, + WatchdogReset = 33, + ConfigChange = 34, +}; + +enum class SecurityEventSeverity : uint8_t +{ + Info = 0, + Low = 1, + Medium = 2, + High = 3, + Critical = 4, +}; + +enum class SourceLayer : uint8_t +{ + System = 0, + Physical = 1, + Network = 2, + Analytics = 3, + CrossLayer = 4, +}; + +// ============================================================================ +// SECURITY EVENT +// ============================================================================ +static constexpr size_t EVENT_DETAILS_MAX_LENGTH = 64; + +struct SecurityEvent +{ + core::timestamp_t timestamp{0}; + SecurityEventType event_type{SecurityEventType::None}; + SecurityEventSeverity severity{SecurityEventSeverity::Info}; + SourceLayer source_layer{SourceLayer::System}; + uint8_t reserved{0}; + char details[EVENT_DETAILS_MAX_LENGTH]{}; + + GS_CONSTEXPR SecurityEvent() noexcept = default; + + GS_CONSTEXPR bool is_valid() const noexcept + { + return event_type != SecurityEventType::None && timestamp > 0; + } +}; + +// ============================================================================ +// EVENT LOGGER โ€” Circular buffer for security events +// ============================================================================ +static constexpr size_t EVENT_LOG_CAPACITY = 64; + +class EventLogger +{ +public: + EventLogger() noexcept = default; + + /** + * @brief Log a new security event. + * If buffer is full, the oldest event is overwritten (circular). + */ + core::Result log_event(SecurityEventType type, + SecurityEventSeverity severity, + SourceLayer layer, + core::timestamp_t timestamp, + const char* details = nullptr) noexcept + { + auto& slot = events_[write_index_]; + slot.timestamp = timestamp; + slot.event_type = type; + slot.severity = severity; + slot.source_layer = layer; + slot.reserved = 0; + + if (details != nullptr) { + std::strncpy(slot.details, details, EVENT_DETAILS_MAX_LENGTH - 1); + slot.details[EVENT_DETAILS_MAX_LENGTH - 1] = '\0'; + } else { + slot.details[0] = '\0'; + } + + write_index_ = (write_index_ + 1) % EVENT_LOG_CAPACITY; + if (count_ < EVENT_LOG_CAPACITY) { + ++count_; + } + + return core::Result::ok(); + } + + /** + * @brief Get the total number of logged events. + */ + GS_NODISCARD size_t event_count() const noexcept + { + return count_; + } + + /** + * @brief Get an event by index (0 = oldest in buffer). + */ + GS_NODISCARD core::Result get_event(size_t index) const noexcept + { + if (index >= count_) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + + size_t actual_index = 0; + if (count_ < EVENT_LOG_CAPACITY) { + actual_index = index; + } else { + actual_index = (write_index_ + index) % EVENT_LOG_CAPACITY; + } + return core::Result(events_[actual_index]); + } + + /** + * @brief Get events within a time range. + * @param start Start timestamp (inclusive) + * @param end End timestamp (inclusive) + * @param out Output buffer + * @param max_out Maximum events to return + * @return Number of events found + */ + size_t get_timeline(core::timestamp_t start, + core::timestamp_t end, + SecurityEvent* out, + size_t max_out) const noexcept + { + size_t found = 0; + for (size_t i = 0; i < count_ && found < max_out; ++i) { + auto result = get_event(i); + if (result.is_ok()) { + const auto& evt = result.value(); + if (evt.timestamp >= start && evt.timestamp <= end) { + out[found++] = evt; + } + } + } + return found; + } + + /** + * @brief Count events of a specific type. + */ + GS_NODISCARD size_t count_by_type(SecurityEventType type) const noexcept + { + size_t count = 0; + for (size_t i = 0; i < count_; ++i) { + auto result = get_event(i); + if (result.is_ok() && result.value().event_type == type) { + ++count; + } + } + return count; + } + + /** + * @brief Count events at or above a severity level. + */ + GS_NODISCARD size_t count_by_severity(SecurityEventSeverity min_severity) const noexcept + { + size_t count = 0; + for (size_t i = 0; i < count_; ++i) { + auto result = get_event(i); + if (result.is_ok() && static_cast(result.value().severity) >= + static_cast(min_severity)) { + ++count; + } + } + return count; + } + + /** + * @brief Get the most recent event. + */ + GS_NODISCARD core::Result latest() const noexcept + { + if (count_ == 0) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + return get_event(count_ - 1); + } + + /** + * @brief Clear all logged events. + */ + void clear() noexcept + { + count_ = 0; + write_index_ = 0; + for (auto& evt : events_) { + evt = SecurityEvent{}; + } + } + +private: + SecurityEvent events_[EVENT_LOG_CAPACITY]{}; + size_t write_index_{0}; + size_t count_{0}; +}; + +} // namespace gridshield::forensics diff --git a/firmware/include/common/forensics/incident_report.hpp b/firmware/include/common/forensics/incident_report.hpp new file mode 100644 index 0000000..cae6841 --- /dev/null +++ b/firmware/include/common/forensics/incident_report.hpp @@ -0,0 +1,249 @@ +/** + * @file incident_report.hpp + * @author Rafi Indra Pramudhito Zuhayr + * @brief Incident report generation from security event logs + * @version 3.1.0 + * @date 2026-03-06 + * + * Correlates events from multiple security layers to generate + * structured incident reports for forensic analysis. + */ + +#pragma once + +#include "core/error.hpp" +#include "core/types.hpp" +#include "forensics/event_logger.hpp" +#include "utils/gs_macros.hpp" + +#include + +namespace gridshield::forensics { + +// ============================================================================ +// ATTACK CLASSIFICATION +// ============================================================================ +enum class AttackType : uint8_t +{ + Unknown = 0, + PhysicalTampering = 1, // Physical layer only + NetworkIntrusion = 2, // Network layer only + ConsumptionFraud = 3, // Analytics layer only + HybridAttack = 4, // Multiple layers compromised + SystemCompromise = 5, // System-level breach +}; + +static constexpr size_t INCIDENT_MAX_EVENTS = 16; + +// ============================================================================ +// INCIDENT REPORT +// ============================================================================ +struct IncidentReport +{ + bool valid{false}; + AttackType attack_type{AttackType::Unknown}; + SecurityEventSeverity max_severity{SecurityEventSeverity::Info}; + + // Affected layers + bool physical_layer_affected{false}; + bool network_layer_affected{false}; + bool analytics_layer_affected{false}; + + // Statistics + uint16_t total_events{0}; + uint16_t critical_events{0}; + uint16_t high_events{0}; + core::timestamp_t first_event_time{0}; + core::timestamp_t last_event_time{0}; + + // Confidence (0-100) that this is a coordinated attack + uint8_t confidence{0}; + + // Snapshot of relevant events + SecurityEvent events[INCIDENT_MAX_EVENTS]{}; + size_t event_count{0}; + + GS_CONSTEXPR IncidentReport() noexcept = default; +}; + +// ============================================================================ +// INCIDENT REPORT GENERATOR +// ============================================================================ +class IncidentReportGenerator +{ +public: + IncidentReportGenerator() noexcept = default; + + /** + * @brief Generate an incident report from the event logger. + * + * Analyzes all logged events, correlates across layers, classifies + * the attack type, and computes a confidence score. + */ + core::Result generate_report(const EventLogger& logger) const noexcept + { + if (logger.event_count() == 0) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + + IncidentReport report{}; + report.valid = true; + report.total_events = static_cast(logger.event_count()); + + // Analyze all events + bool has_physical = false; + bool has_network = false; + bool has_analytics = false; + SecurityEventSeverity max_sev = SecurityEventSeverity::Info; + + size_t events_to_copy = (logger.event_count() < INCIDENT_MAX_EVENTS) ? logger.event_count() + : INCIDENT_MAX_EVENTS; + + // Copy the most recent events to the report + size_t start_idx = (logger.event_count() > INCIDENT_MAX_EVENTS) + ? (logger.event_count() - INCIDENT_MAX_EVENTS) + : 0; + + for (size_t i = 0; i < events_to_copy; ++i) { + auto result = logger.get_event(start_idx + i); + if (result.is_ok()) { + report.events[i] = result.value(); + report.event_count = i + 1; + } + } + + // Analyze all events (not just copied ones) + for (size_t i = 0; i < logger.event_count(); ++i) { + auto result = logger.get_event(i); + if (result.is_error()) { + continue; + } + + const auto& evt = result.value(); + + // Track first/last times + if (report.first_event_time == 0 || evt.timestamp < report.first_event_time) { + report.first_event_time = evt.timestamp; + } + if (evt.timestamp > report.last_event_time) { + report.last_event_time = evt.timestamp; + } + + // Track max severity + if (static_cast(evt.severity) > static_cast(max_sev)) { + max_sev = evt.severity; + } + + // Count severity levels + if (evt.severity == SecurityEventSeverity::Critical) { + ++report.critical_events; + } else if (evt.severity == SecurityEventSeverity::High) { + ++report.high_events; + } + + // Track affected layers + switch (evt.source_layer) { + case SourceLayer::Physical: + has_physical = true; + break; + case SourceLayer::Network: + has_network = true; + break; + case SourceLayer::Analytics: + has_analytics = true; + break; + default: + break; + } + } + + report.max_severity = max_sev; + report.physical_layer_affected = has_physical; + report.network_layer_affected = has_network; + report.analytics_layer_affected = has_analytics; + + // Classify attack type + report.attack_type = classify_attack(has_physical, has_network, has_analytics); + + // Compute confidence score + report.confidence = compute_confidence(report); + + return core::Result(report); + } + +private: + /** + * @brief Classify the attack based on which layers are affected. + */ + static AttackType classify_attack(bool physical, bool network, bool analytics) noexcept + { + int layer_count = (physical ? 1 : 0) + (network ? 1 : 0) + (analytics ? 1 : 0); + + if (layer_count >= 2) { + return AttackType::HybridAttack; + } + if (physical) { + return AttackType::PhysicalTampering; + } + if (network) { + return AttackType::NetworkIntrusion; + } + if (analytics) { + return AttackType::ConsumptionFraud; + } + return AttackType::Unknown; + } + + /** + * @brief Compute confidence that this is a real, coordinated attack. + * + * Higher confidence when: + * - Multiple layers affected (+30) + * - Critical severity events present (+25) + * - High event count (+15) + * - Events in close temporal proximity (+15) + * - Multiple event types (+15) + */ + static uint8_t compute_confidence(const IncidentReport& report) noexcept + { + uint8_t confidence = 0; + + // Multiple layers affected โ†’ likely coordinated + int layers = (report.physical_layer_affected ? 1 : 0) + + (report.network_layer_affected ? 1 : 0) + + (report.analytics_layer_affected ? 1 : 0); + if (layers >= 2) { + confidence += 30; + } else if (layers == 1) { + confidence += 10; + } + + // Critical events present + if (report.critical_events > 0) { + confidence += 25; + } else if (report.high_events > 0) { + confidence += 15; + } + + // High event count + if (report.total_events >= 10) { + confidence += 15; + } else if (report.total_events >= 5) { + confidence += 10; + } + + // Temporal proximity (events within 60 seconds) + if (report.first_event_time > 0 && report.last_event_time > 0) { + uint64_t duration = report.last_event_time - report.first_event_time; + static constexpr uint64_t SIXTY_SECONDS_MS = 60000; + if (duration > 0 && duration <= SIXTY_SECONDS_MS) { + confidence += 15; + } + } + + // Cap at 100 + return (confidence > 100) ? 100 : confidence; + } +}; + +} // namespace gridshield::forensics diff --git a/firmware/test_app/.cache/clangd/index/test_hkdf.cpp.DCBF07BC85EB153F.idx b/firmware/test_app/.cache/clangd/index/test_hkdf.cpp.DCBF07BC85EB153F.idx index 63ce9377bc6055254b9228847ce7903e94f4e6cb..202920bb33a1baac6bdf73229766096343bca926 100644 GIT binary patch delta 20 ccmccMe!+didnJyW?At7>d#t1X`2YX_ diff --git a/firmware/test_app/main/test_forensics.cpp b/firmware/test_app/main/test_forensics.cpp new file mode 100644 index 0000000..5648daf --- /dev/null +++ b/firmware/test_app/main/test_forensics.cpp @@ -0,0 +1,421 @@ +/** + * @file test_forensics.cpp + * @brief Unit tests for v3.1.0 forensics module + * + * Tests EventLogger, IncidentReportGenerator, and cross-layer correlation. + */ + +#include "unity.h" + +#include "forensics/event_logger.hpp" +#include "forensics/incident_report.hpp" + +using namespace gridshield; +using namespace gridshield::forensics; + +// ============================================================================ +// EventLogger Tests +// ============================================================================ + +static void test_event_log_basic() +{ + EventLogger logger; + TEST_ASSERT_EQUAL(0, logger.event_count()); + + auto result = logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + "Front panel removed"); + TEST_ASSERT_TRUE(result.is_ok()); + TEST_ASSERT_EQUAL(1, logger.event_count()); + + auto evt = logger.get_event(0); + TEST_ASSERT_TRUE(evt.is_ok()); + TEST_ASSERT_EQUAL(static_cast(SecurityEventType::CasingOpened), + static_cast(evt.value().event_type)); + TEST_ASSERT_EQUAL(1000, evt.value().timestamp); + TEST_ASSERT_EQUAL_STRING("Front panel removed", evt.value().details); +} + +static void test_event_log_multiple() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 3000, + nullptr); + TEST_ASSERT_EQUAL(3, logger.event_count()); +} + +static void test_event_log_latest() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + + auto latest = logger.latest(); + TEST_ASSERT_TRUE(latest.is_ok()); + TEST_ASSERT_EQUAL(2000, latest.value().timestamp); + TEST_ASSERT_EQUAL(static_cast(SecurityEventType::AnomalyDetected), + static_cast(latest.value().event_type)); +} + +static void test_event_log_empty_latest() +{ + EventLogger logger; + auto latest = logger.latest(); + TEST_ASSERT_TRUE(latest.is_error()); +} + +static void test_event_log_timeline() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 3000, + nullptr); + logger.log_event(SecurityEventType::SystemReboot, + SecurityEventSeverity::Info, + SourceLayer::System, + 5000, + nullptr); + + // Get events in [1500, 3500] range + SecurityEvent out[4]; + size_t found = logger.get_timeline(1500, 3500, out, 4); + TEST_ASSERT_EQUAL(2, found); + TEST_ASSERT_EQUAL(2000, out[0].timestamp); + TEST_ASSERT_EQUAL(3000, out[1].timestamp); +} + +static void test_event_log_count_by_type() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 2000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 3000, + nullptr); + + TEST_ASSERT_EQUAL(2, logger.count_by_type(SecurityEventType::CasingOpened)); + TEST_ASSERT_EQUAL(1, logger.count_by_type(SecurityEventType::AnomalyDetected)); + TEST_ASSERT_EQUAL(0, logger.count_by_type(SecurityEventType::SystemReboot)); +} + +static void test_event_log_count_by_severity() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 3000, + nullptr); + logger.log_event(SecurityEventType::SystemReboot, + SecurityEventSeverity::Info, + SourceLayer::System, + 4000, + nullptr); + + // >= High + TEST_ASSERT_EQUAL(2, logger.count_by_severity(SecurityEventSeverity::High)); + // >= Critical + TEST_ASSERT_EQUAL(1, logger.count_by_severity(SecurityEventSeverity::Critical)); + // >= Info (all) + TEST_ASSERT_EQUAL(4, logger.count_by_severity(SecurityEventSeverity::Info)); +} + +static void test_event_log_clear() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + nullptr); + TEST_ASSERT_EQUAL(2, logger.event_count()); + + logger.clear(); + TEST_ASSERT_EQUAL(0, logger.event_count()); + TEST_ASSERT_TRUE(logger.latest().is_error()); +} + +static void test_event_log_circular_overflow() +{ + EventLogger logger; + + // Fill the buffer completely (capacity = 64) + for (size_t i = 0; i < EVENT_LOG_CAPACITY + 5; ++i) { + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Low, + SourceLayer::Analytics, + static_cast(1000 + i * 100), + nullptr); + } + + // Should be capped at capacity + TEST_ASSERT_EQUAL(EVENT_LOG_CAPACITY, logger.event_count()); + + // Oldest event should be the 6th event (first 5 were overwritten) + auto oldest = logger.get_event(0); + TEST_ASSERT_TRUE(oldest.is_ok()); + TEST_ASSERT_EQUAL(1500, oldest.value().timestamp); // 1000 + 5*100 + + // Latest should be the last event + auto latest = logger.latest(); + TEST_ASSERT_TRUE(latest.is_ok()); + // 1000 + (64+4) * 100 = 1000 + 6800 = 7800 + TEST_ASSERT_EQUAL(7800, latest.value().timestamp); +} + +static void test_event_log_invalid_index() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + + auto result = logger.get_event(5); + TEST_ASSERT_TRUE(result.is_error()); +} + +// ============================================================================ +// IncidentReportGenerator Tests +// ============================================================================ + +static void test_report_single_layer_physical() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::PhysicalShock, + SecurityEventSeverity::Medium, + SourceLayer::Physical, + 1500, + nullptr); + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + TEST_ASSERT_TRUE(report.valid); + TEST_ASSERT_EQUAL(static_cast(AttackType::PhysicalTampering), + static_cast(report.attack_type)); + TEST_ASSERT_TRUE(report.physical_layer_affected); + TEST_ASSERT_FALSE(report.network_layer_affected); + TEST_ASSERT_EQUAL(2, report.total_events); +} + +static void test_report_hybrid_attack() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 1200, + nullptr); + logger.log_event(SecurityEventType::ConsumptionDrop, + SecurityEventSeverity::High, + SourceLayer::Analytics, + 1400, + nullptr); + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + TEST_ASSERT_EQUAL(static_cast(AttackType::HybridAttack), + static_cast(report.attack_type)); + TEST_ASSERT_TRUE(report.physical_layer_affected); + TEST_ASSERT_TRUE(report.network_layer_affected); + TEST_ASSERT_TRUE(report.analytics_layer_affected); + TEST_ASSERT_TRUE(report.confidence >= 50); // multi-layer + critical + temporal proximity +} + +static void test_report_confidence_scoring() +{ + EventLogger logger; + // Create a high-confidence scenario: multi-layer, critical, close temporal proximity + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::Critical, + SourceLayer::Physical, + 1000, + nullptr); + logger.log_event(SecurityEventType::ReplayAttackDetected, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 1100, + nullptr); + logger.log_event(SecurityEventType::ZeroConsumption, + SecurityEventSeverity::High, + SourceLayer::Analytics, + 1200, + nullptr); + + for (int i = 0; i < 8; ++i) { + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + static_cast(1300 + i * 100), + nullptr); + } + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + // Multi-layer (+30), critical (+25), 11 events (+15), close in time (+15) = 85 + TEST_ASSERT_TRUE(report.confidence >= 70); +} + +static void test_report_empty_logger() +{ + EventLogger logger; + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_error()); +} + +static void test_report_network_intrusion() +{ + EventLogger logger; + logger.log_event(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::High, + SourceLayer::Network, + 1000, + nullptr); + logger.log_event(SecurityEventType::UnauthorizedDevice, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 1100, + nullptr); + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + TEST_ASSERT_EQUAL(static_cast(AttackType::NetworkIntrusion), + static_cast(report.attack_type)); + TEST_ASSERT_FALSE(report.physical_layer_affected); + TEST_ASSERT_TRUE(report.network_layer_affected); +} + +static void test_report_event_snapshot() +{ + EventLogger logger; + logger.log_event(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + "door open"); + logger.log_event(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + "energy spike"); + + IncidentReportGenerator gen; + auto result = gen.generate_report(logger); + TEST_ASSERT_TRUE(result.is_ok()); + + const auto& report = result.value(); + TEST_ASSERT_EQUAL(2, report.event_count); + TEST_ASSERT_EQUAL_STRING("door open", report.events[0].details); + TEST_ASSERT_EQUAL_STRING("energy spike", report.events[1].details); +} + +// ============================================================================ +// TEST SUITE ENTRY POINT +// ============================================================================ + +extern "C" void test_forensics_suite(void) +{ + // EventLogger tests + RUN_TEST(test_event_log_basic); + RUN_TEST(test_event_log_multiple); + RUN_TEST(test_event_log_latest); + RUN_TEST(test_event_log_empty_latest); + RUN_TEST(test_event_log_timeline); + RUN_TEST(test_event_log_count_by_type); + RUN_TEST(test_event_log_count_by_severity); + RUN_TEST(test_event_log_clear); + RUN_TEST(test_event_log_circular_overflow); + RUN_TEST(test_event_log_invalid_index); + + // IncidentReportGenerator tests + RUN_TEST(test_report_single_layer_physical); + RUN_TEST(test_report_hybrid_attack); + RUN_TEST(test_report_confidence_scoring); + RUN_TEST(test_report_empty_logger); + RUN_TEST(test_report_network_intrusion); + RUN_TEST(test_report_event_snapshot); +} diff --git a/firmware/test_app/main/test_main.cpp b/firmware/test_app/main/test_main.cpp index 8cd1ca8..cc14694 100644 --- a/firmware/test_app/main/test_main.cpp +++ b/firmware/test_app/main/test_main.cpp @@ -29,6 +29,7 @@ extern void test_telemetry_suite(void); extern "C" void test_mqtt_suite(void); extern "C" void test_sensors_suite(void); extern "C" void test_ota_power_suite(void); +extern "C" void test_forensics_suite(void); extern "C" void app_main(void) { @@ -61,6 +62,7 @@ extern "C" void app_main(void) test_mqtt_suite(); test_sensors_suite(); test_ota_power_suite(); + test_forensics_suite(); int failures = UNITY_END(); From 166341f0875453fbd948a9c1fd164135e9c0b289 Mon Sep 17 00:00:00 2001 From: zuudevs Date: Sat, 7 Mar 2026 09:27:20 +0700 Subject: [PATCH 2/4] feat: release v3.2.0 with data export API and evidence preservation Backend: - Added data export API endpoints to download readings, alerts, and anomalies in CSV format. - Expanded the pytest test suite to 41 passing tests. Frontend: - Integrated fleet management API (registration modal, status indicators, and deletion). - Added CSV export functionality across the dashboard, alerts, and anomalies pages. Firmware (C/C++): - Implemented EvidenceStore utilizing a circular buffer to preserve up to 32 EvidenceSnapshot records. - Added FNV-1a hash chain verification to ensure data integrity and enable tamper detection. - Introduced 8 new unit tests for the evidence preservation logic. CI & Documentation: - Added 'backend-test' job (Python 3.11, pytest) to the CI pipeline, bringing the total to 7 jobs. - Updated ROADMAP.md and CHANGELOG.md to reflect v3.2.0 completion. Closes #33 Refs #44 --- .github/workflows/build.yml | 19 ++ backend/app/export.py | 139 ++++++++ backend/app/main.py | 6 +- backend/test_gridshield.db | Bin 53248 -> 53248 bytes backend/test_results.txt | Bin 10718 -> 7778 bytes backend/tests/test_export.py | 78 +++++ docs/CHANGELOG.md | 34 ++ docs/ROADMAP.md | 45 ++- .../common/forensics/evidence_store.hpp | 277 +++++++++++++++ .../index/test_main.cpp.7A6A54946A18A821.idx | Bin 9714 -> 9806 bytes .../test_app/main/test_evidence_store.cpp | 231 +++++++++++++ firmware/test_app/main/test_main.cpp | 2 + frontend/src/api.js | 69 ++++ frontend/src/pages/alerts.js | 118 ++++--- frontend/src/pages/anomalies.js | 67 ++-- frontend/src/pages/dashboard.js | 176 +++++----- frontend/src/pages/fleet.js | 318 +++++++++++------- frontend/src/style.css | 104 ++++++ 18 files changed, 1393 insertions(+), 290 deletions(-) create mode 100644 backend/app/export.py create mode 100644 backend/tests/test_export.py create mode 100644 firmware/include/common/forensics/evidence_store.hpp create mode 100644 firmware/test_app/main/test_evidence_store.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 78c2705..9d1c447 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/backend/app/export.py b/backend/app/export.py new file mode 100644 index 0000000..911410c --- /dev/null +++ b/backend/app/export.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index ca0acc1..6df7c7b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from .database import Base, engine +from .export import router as export_router from .meters import router as meters_router from .routes import router @@ -25,7 +26,7 @@ async def lifespan(app: FastAPI): description="REST API for GridShield AMI Security System โ€” " "Meter data ingestion, tamper alerts, anomaly monitoring, " "and fleet management.", - version="3.1.0", + version="3.2.0", lifespan=lifespan, ) @@ -41,6 +42,7 @@ async def lifespan(app: FastAPI): # Routes app.include_router(router) app.include_router(meters_router) +app.include_router(export_router) @app.get("/", tags=["Root"]) @@ -48,6 +50,6 @@ def root(): """Health check.""" return { "name": "GridShield API", - "version": "3.1.0", + "version": "3.2.0", "status": "running", } diff --git a/backend/test_gridshield.db b/backend/test_gridshield.db index 49f49ea81e27a0ca2bcc4c8a2a4c528ce99086c5..c3c8e851a873861ed5f520b7744b70deb98e093b 100644 GIT binary patch delta 379 zcmZozz}&Ead4iOX>?H;U23{!UVPKHjFj2>dN%qplgbAE1ahz=8lhe7>Co6HuOnFeEHJUsb$g}6u z*nFNZLqO16!NADM(9p`zT+hVP+}zA$vWL8k6tbv=fsutdqYiU)0{aDR=E;Hb!gk2g zW~Qb_1`H>_(ppTM42+J9%uE~{DMkiHX1WH(x(3LKO^qxqEhe9rmli`7H8C_XHUS&3 z$UtC`g1{z+c6(%pnHrdwnHx-=QE!DTYGPq#Y+^9^alP_pwh6!Z7c~eha!{BUz`mLN Hz)yYvzz$%_ delta 389 zcmZozz}&Ead4iM>=N$$H23{!UVPN1mFj2>diSy3JgbAE{g`Di-wyKPcyqS3^sTGqa za7j$&=hB+IfJ=>W{p2s4>XVze5WqzolPB_sO*Z5aoxBYwp30*(`904s&Prx>aYs$YM$yS1dBrCe@`+8B;8U2qoKJ&s z&g8ee+LPOXJPSUV&FA?t1O&|#42`W!EUXMI^~{aTEiEl4d&tX3A&VNATbdd$>M%zq zuwT$-o*XDIY=y_Zhr}w+kx$YIVZsHJ9fyf=0xeF27^Aj?hHr)@g0n zi44Bsc>sTX?RTN=v=BkPdPPusnCmrkyLM_%>{o{}pt^~0ij)(FbZG;pE3cmsIJIw8 z#M?zGghyDzdW0kU6CARx#(rh@Z`YvQ98$l<#iWxJnCda1PU9|*6U`(0Y9p#mFAsYqYum78xrU;%GfqpYX>Y7r-V#E+q%Y@@k9h{1}(3AsTrd-?CEX2WP) zcy1q^ouK{WBHN6hDTxwjl>sHb)lBCVV|Z0|$1l;o0mjF#HSkRr*Pe5QR<4v&#V`a5vX6W+ulVm>FoXb&%uMf;+9MbFxy$iC#x zv6(sFYSkFa5=wCvl8^KAKHBkB(YKCj$iwH1%A$qN?^+-3AttG=sK*dl<$yh1AeHP% zZ$mK7z`eT&GtiA)eYP7IWzXJM6i-wQCC*xWU404GY81E~^KB8Z+-*fJ)$KpQv6`3b zyL?@&8aQjKt{Z?$kw!Ro2}c>n?^B^)po=+|{?_eV7G1AO`_zs582u^4=#XP%@5Hq&eqtod=lYAPPMBc0pz}@-d@x7+3VAPS!@Y09y zohFyZ@Sq+&sD0kq{qyqU=ABjjrSeSl9RJV>HzkLuRua~Fy;Xj%vu;@9&%Fs}GkKZs z$cK}1X4TGH`F-fqivCOYT~7Ag^WT>Kw^ie+gQ1gwb~emJkQpjHYxDI*xaT)ruaz zBiMn4da^CXW~@+;M<+wyJ#7{J(9rFb9g61!nS^Mw|1^aEAF5BSSfzfh`i-Y6d+ecy P+JmQ7`08g9jzahk3WGG* literal 10718 zcmeI2-%lGy5XbkqQvU}_Ri#7$#!3_V-iQa$~hk zbhqgU$L?qMlN-6dWKG?rWRKlI^1f)CYu9kQ`n{#A=FZ%WdVH9V-f(rd~Cn!e9J$Q)K=?-$JJA>iKO@Rw6EV6 z?!6!HqwcSDA2i8a(cZ+5-_oxe-48W)^tB_c@NIa9lGLA%!aG+Uaa}!X8adg@oNChc zzP@|H)RmSdUJ8chLQZhO)rNJCq(Pf}h} z5@u>`ds$fEnPi;n`rV!AuIAqP?_Kw+``e=oG9M0MN7ybkznSJXbs+A#&-#qL{%$gu z4e@>@#D7XQx{dn83X&S_&wC`bIT1S6bEG%3xX`9uhVbw^A%qiPfTk=Ft7&C4Xsf;> z>#nluo9pmW2w7{^xGC?v(Kn3nQJysvr{N79`Iz}q+G5cyj8+BttK6}oEu<|WA6e2a z%Dl`lzjy?g(eJhN`gLyIFd{w|8EBCqz`mikOr&X{^Ab_cy0S zi(1@0i4BMvEhe>ju`7i5G|R4yQg$6kFUH=J9PfUuR(zlR6^1{(?Va{(B9>&oru;a_ zU4>B_u8dKmy!*pB&`huOF%HUO&%ILiyf2&`@d{@KY}w++q>l5kma*ed4c@eil zw#1V|pR_)&wZ7=L2qOp&DsoUYgv)FYO&Y>#%(Fg1Ryi*gj<&j}QNvk{e~5ND->^3p zc@2?Z=#yUSRZjB<75Qg(fjTc%U69+q-YT4r9%b~jk#PR=aNdQQ7``u(K+ii|3m6ZWq#mpmprERCcdY9_GomG|JD z&-_{bfU^U+=zQ#DF;+SD5DC6c+594h5Iw^k+3doym=$|JD&@C_z!+`4C~7sVWf`@H zz?v7oMh?%jgQL>O>mhJsBX}eApLCq~PB!p7!F$vIHm?C4Oa2%6$=IJbLyn7>V4#cV zcuN)eR%aqM!RE_r07RBqOv@@A>}(4E!K0M%!IKmiz74t#68tpFe2}!$FOWY`6$jy0UoPEKo8s zNQ%8_F{Ajs^52gaVa#zq4vg#Ne2Xz-8!`HiTpDZ9)nlf(S3V{jk;garkAe$nZKR98 z<@?*w4~`ZeJ${Z3dyHicsQMqTusc30Vb{D^CHmK?JkY8p=lZ*OPNUejevBV9oX2l> zssFp;6so~Q2*iaUdqa(mUHwO#_qgM{=xuc$zgZ@M{n~2%csM7vY$RIuYo3g|@o7Hx ze|{&jinc5QEBGKOQ}Gjb+qydQHnWy+nyc$68I^56_O^P%Fcw{IO9t`%mYxmOgXojY zm__1S^Vpg?r=2?Me$0X91fum$oZ-LYIP;Ld;@m*&dZ4p>_S6^R@rG=FS2-0T^!3IA F;r~)ohrs{< diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py new file mode 100644 index 0000000..c6dd806 --- /dev/null +++ b/backend/tests/test_export.py @@ -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] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d0b3567..1b50a5e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,40 @@ 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). +## [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. + ## [3.1.0] - 2026-03-06 ### Added diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 57daadb..1e6d8b7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,8 +3,8 @@ Future development plans and feature roadmap for GridShield AMI Security System. **Last Updated:** March 2026 -**Current Version:** 3.1.0-fw -**Next Target:** 3.2.0 (Q2 2027) +**Current Version:** 3.2.0 +**Next Target:** 3.3.0 (Q2 2027) --- @@ -224,6 +224,44 @@ GridShield aims to become the **industry-standard open-source security framework --- +### Q2 2027 โ€” Integration & Export Enhancement (v3.2.0) + +**Target Release:** June 2027 (completed March 2026) + +#### High Priority + +- [x] **Data Export API** + - [x] CSV export endpoints (readings, alerts, anomalies) + - [x] StreamingResponse with Content-Disposition headers + - [x] Meter-specific export filtering + +- [x] **Frontend Fleet Integration** + - [x] Fleet page refactored to use Meter CRUD API + - [x] Meter registration modal (create/delete) + - [x] Meter status indicators (online/offline/tampered) + - [x] CSV export buttons on all data pages + +- [x] **Evidence Preservation** (Firmware) + - [x] EvidenceStore with circular buffer (32 snapshots) + - [x] Hash chain integrity (FNV-1a tamper detection) + - [x] SensorSnapshot capture (energy, voltage, current, temperature, accelerometer) + - [x] Chain verification function + +#### Medium Priority + +- [x] **CI Enhancement** + - [x] Backend pytest job in GitHub Actions (7-job pipeline) + +- [x] **Backend Testing** + - [x] Export endpoint tests (6 tests) + - [x] Total backend tests: 37+ (31 existing + 6 new) + +- [x] **Firmware Tests** + - [x] EvidenceStore tests (8 tests) + - [x] Total firmware test count: 176 (168 + 8) + +--- + ## Feature Backlogs ### Core Features @@ -280,7 +318,7 @@ GridShield aims to become the **industry-standard open-source security framework - [x] **Forensics** - [x] Attack signature event logger (SecurityEvent + EventLogger) - [x] Incident timeline reconstruction (get_timeline) - - [ ] Evidence preservation + - [x] Evidence preservation (EvidenceStore + hash chain) - [x] Automated reporting (IncidentReportGenerator) --- @@ -430,6 +468,7 @@ Features are prioritized based on: | **3.0.0** | December 2026 | Cloud integration, ML analytics, 206 tests (total) | | **3.0.1** | March 2026 | CI fixes, hardware testing (ESP32-D0WD), HKDF test fix, 152 tests | | **3.1.0** | March 2026 | Fleet management API, server-side anomaly detection, forensics module, backend tests (31), firmware tests (+16 = 168 total) | +| **3.2.0** | March 2026 | Data export API (CSV), frontend fleet integration, evidence preservation, backend CI tests, 37+ backend tests, 176 firmware tests | --- diff --git a/firmware/include/common/forensics/evidence_store.hpp b/firmware/include/common/forensics/evidence_store.hpp new file mode 100644 index 0000000..3dc9fc5 --- /dev/null +++ b/firmware/include/common/forensics/evidence_store.hpp @@ -0,0 +1,277 @@ +/** + * @file evidence_store.hpp + * @author Rafi Indra Pramudhito Zuhayr + * @brief Evidence preservation for forensic analysis + * @version 3.2.0 + * @date 2026-03-07 + * + * Stores immutable evidence snapshots with a tamper-evident hash chain. + * Each snapshot captures security event context with raw sensor readings + * and is linked to the previous snapshot via a chained hash, + * enabling integrity verification of the entire evidence log. + */ + +#pragma once + +#include "core/error.hpp" +#include "core/types.hpp" +#include "forensics/event_logger.hpp" +#include "utils/gs_macros.hpp" + +#include +#include +#include + + +namespace gridshield::forensics { + +// ============================================================================ +// CONSTANTS +// ============================================================================ +static constexpr size_t EVIDENCE_STORE_CAPACITY = 32; +static constexpr size_t EVIDENCE_HASH_SIZE = 32; // 256-bit hash +static constexpr size_t EVIDENCE_NOTES_MAX = 48; + +// ============================================================================ +// SENSOR SNAPSHOT โ€” raw sensor state at time of evidence capture +// ============================================================================ +struct SensorSnapshot +{ + uint32_t energy_wh{0}; + uint32_t voltage_mv{0}; + uint32_t current_ma{0}; + int32_t temperature_c10{0}; // temperature ร— 10 + uint16_t power_factor{0}; + uint16_t accelerometer_mg{0}; // peak g-force in milli-g + + GS_CONSTEXPR SensorSnapshot() noexcept = default; +}; + +// ============================================================================ +// EVIDENCE SNAPSHOT โ€” immutable forensic record +// ============================================================================ +struct EvidenceSnapshot +{ + core::timestamp_t timestamp{0}; + SecurityEventType event_type{SecurityEventType::None}; + SecurityEventSeverity severity{SecurityEventSeverity::Info}; + SourceLayer source_layer{SourceLayer::System}; + uint8_t sequence{0}; // monotonic counter (mod 256) + SensorSnapshot sensors{}; + char notes[EVIDENCE_NOTES_MAX]{}; + uint8_t hash[EVIDENCE_HASH_SIZE]{}; // hash of this snapshot + uint8_t prev_hash[EVIDENCE_HASH_SIZE]{}; // hash chain link + + GS_CONSTEXPR bool is_valid() const noexcept + { + return event_type != SecurityEventType::None && timestamp > 0; + } +}; + +// ============================================================================ +// SOFTWARE SHA-256 โ€” lightweight (header-only, no heap, no mbedTLS dep) +// ============================================================================ +namespace detail { + +/** + * Simple deterministic hash for evidence chain. + * Uses a Fowlerโ€“Nollโ€“Vo-1a variant (FNV-1a 256-bit) for lightweight + * tamper detection. This is NOT a cryptographic hash โ€” it provides + * change detection, not security against a motivated attacker. + */ +inline void evidence_hash(const uint8_t* data, size_t len, uint8_t out[EVIDENCE_HASH_SIZE]) noexcept +{ + // FNV-1a offset basis spread across 32 bytes + uint8_t state[EVIDENCE_HASH_SIZE] = { + 0xcb, 0xf2, 0x9c, 0xe4, 0x84, 0x22, 0x23, 0x25, 0x14, 0x07, 0x3d, + 0xb5, 0xbf, 0x5b, 0x1e, 0x73, 0x3b, 0x0e, 0x77, 0x0a, 0xda, 0xe6, + 0x37, 0x39, 0x42, 0x11, 0xa5, 0xb3, 0x59, 0x8c, 0x2f, 0x45, + }; + + for (size_t i = 0; i < len; ++i) { + // XOR-fold the byte across the state + const uint8_t b = data[i]; + for (size_t j = 0; j < EVIDENCE_HASH_SIZE; ++j) { + state[j] ^= b; + // FNV multiply: state[j] = state[j] * 0x01000193 + // Simplified: shift-add for embedded + uint16_t v = static_cast(state[j]); + v = static_cast(v + (v << 1) + (v << 4) + (v << 7)); + state[j] = static_cast(v & 0xFF); + // Cascade carry + state[(j + 1) % EVIDENCE_HASH_SIZE] ^= static_cast(v >> 8); + } + } + + std::memcpy(out, state, EVIDENCE_HASH_SIZE); +} + +} // namespace detail + +// ============================================================================ +// EVIDENCE STORE โ€” Circular buffer with hash chain +// ============================================================================ +class EvidenceStore +{ +public: + EvidenceStore() noexcept = default; + + /** + * @brief Preserve a new evidence snapshot. + * Links to previous snapshot via hash chain. Circular โ€” oldest + * evidence is overwritten when capacity is reached. + */ + core::Result preserve(SecurityEventType type, + SecurityEventSeverity severity, + SourceLayer layer, + core::timestamp_t timestamp, + const SensorSnapshot& sensors, + const char* notes = nullptr) noexcept + { + auto& slot = snapshots_[write_index_]; + slot.timestamp = timestamp; + slot.event_type = type; + slot.severity = severity; + slot.source_layer = layer; + slot.sequence = sequence_++; + slot.sensors = sensors; + + if (notes != nullptr) { + std::strncpy(slot.notes, notes, EVIDENCE_NOTES_MAX - 1); + slot.notes[EVIDENCE_NOTES_MAX - 1] = '\0'; + } else { + slot.notes[0] = '\0'; + } + + // Copy previous hash into chain link + if (count_ > 0) { + size_t prev_idx = (write_index_ == 0) ? EVIDENCE_STORE_CAPACITY - 1 : write_index_ - 1; + std::memcpy(slot.prev_hash, snapshots_[prev_idx].hash, EVIDENCE_HASH_SIZE); + } else { + std::memset(slot.prev_hash, 0, EVIDENCE_HASH_SIZE); + } + + // Compute hash of this snapshot (over all fields except hash itself) + compute_hash(slot); + + write_index_ = (write_index_ + 1) % EVIDENCE_STORE_CAPACITY; + if (count_ < EVIDENCE_STORE_CAPACITY) { + ++count_; + } + + return core::Result::ok(); + } + + /** + * @brief Get the number of stored evidence snapshots. + */ + GS_NODISCARD size_t evidence_count() const noexcept + { + return count_; + } + + /** + * @brief Get an evidence snapshot by index (0 = oldest). + */ + GS_NODISCARD core::Result get_evidence(size_t index) const noexcept + { + if (index >= count_) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + + size_t actual = 0; + if (count_ < EVIDENCE_STORE_CAPACITY) { + actual = index; + } else { + actual = (write_index_ + index) % EVIDENCE_STORE_CAPACITY; + } + return core::Result(snapshots_[actual]); + } + + /** + * @brief Get the latest evidence snapshot. + */ + GS_NODISCARD core::Result latest() const noexcept + { + if (count_ == 0) { + return GS_MAKE_ERROR(core::ErrorCode::InvalidParameter); + } + return get_evidence(count_ - 1); + } + + /** + * @brief Verify the hash chain integrity of all stored evidence. + * @return true if chain is intact, false if tampered. + */ + GS_NODISCARD bool verify_chain() const noexcept + { + if (count_ == 0) { + return true; + } + + for (size_t i = 0; i < count_; ++i) { + auto result = get_evidence(i); + if (!result.is_ok()) { + return false; + } + const auto& snap = result.value(); + + // Verify this snapshot's own hash + EvidenceSnapshot temp = snap; + std::memset(temp.hash, 0, EVIDENCE_HASH_SIZE); + uint8_t recomputed[EVIDENCE_HASH_SIZE]{}; + compute_hash_raw(temp, recomputed); + if (std::memcmp(recomputed, snap.hash, EVIDENCE_HASH_SIZE) != 0) { + return false; + } + + // Verify chain link (skip first entry which has all-zero prev_hash) + if (i > 0) { + auto prev_result = get_evidence(i - 1); + if (!prev_result.is_ok()) { + return false; + } + if (std::memcmp(snap.prev_hash, prev_result.value().hash, EVIDENCE_HASH_SIZE) != + 0) { + return false; + } + } + } + return true; + } + + /** + * @brief Clear all evidence. + */ + void clear() noexcept + { + count_ = 0; + write_index_ = 0; + sequence_ = 0; + for (auto& snap : snapshots_) { + snap = EvidenceSnapshot{}; + } + } + +private: + void compute_hash(EvidenceSnapshot& snap) const noexcept + { + std::memset(snap.hash, 0, EVIDENCE_HASH_SIZE); + compute_hash_raw(snap, snap.hash); + } + + static void compute_hash_raw(const EvidenceSnapshot& snap, + uint8_t out[EVIDENCE_HASH_SIZE]) noexcept + { + // Hash over the entire snapshot struct (hash field is zeroed) + const auto* raw = reinterpret_cast(&snap); + detail::evidence_hash(raw, sizeof(EvidenceSnapshot), out); + } + + EvidenceSnapshot snapshots_[EVIDENCE_STORE_CAPACITY]{}; + size_t write_index_{0}; + size_t count_{0}; + uint8_t sequence_{0}; +}; + +} // namespace gridshield::forensics diff --git a/firmware/test_app/.cache/clangd/index/test_main.cpp.7A6A54946A18A821.idx b/firmware/test_app/.cache/clangd/index/test_main.cpp.7A6A54946A18A821.idx index 50dacf079248474653ecebef2af2fd791ff8ac67..d24a4a737a33949f38c744969e061d74f31fc5c7 100644 GIT binary patch delta 705 zcmez5ea=TV$kWZuO^t!UImNRgH?<^@g@J)V1c-}EiZXjQ%BIP3rsWr<<`riq7jG_? zRb&)lD6Y&+Dq&(^kU4$Ot$B|E4-dn-$*biQ7}rid10$+G<+*Kf7`A_M8_1j68D~by#%xVaBG(EL?DHK7RtE$Q(u|VW-KwN-E6eEasC< zlvIpOSWJXr`e8Jq6gWI2VEXSysa+3Wdv69vKhWIC-9Y_zEOwJODXB=?ve?4)e@uV4 z!mc4}9iz+~Mimy7$sd90j982&ODL-_Te4VAwpUh(vSG22fZ1a{xu;yn@&65wdsX;V zWMKBef|f~g4kL1i0*!TGae!+tDN$)r=x}_>$O6_rc^c3fV;1Addx5k8i^1e4K-!+g zeloX;impD3z9`Jvh-Cg)!LLPsK-L0-18%LAz@fQYXO{otoSdyx4OoH-I(Udw+wep_|2zUpBB^zOP; delta 681 zcmX@-^T}H_$kWa3l_~>+bBbq0ZfZ#)3j+g#2oM*S6lGRylueV}JVRELQMkA=Hz}Bj zfkEck}?t_o?wPEI}v`ZYD3JI$b7V`7(tH2D3{AE$H{%Y$- z9)@KI1#$v%a0Q=Z8a_ZpWQfld(MjhMxHs0 z+AP}qFmuyn7B09ppFe?7WDcW)u)}0$B^72f7PHA&K-!qac=ALgQEB8Lxf`W+J$&uG z86fpQb0!}IsqDxm#zJ|KpUI!>G!l$_2A;@=g_dVJ8+RxByVzg~Nq! fGPkNU4~qv}m|?PmsxwDzhr5O6zkcn>-KvKHYZ<1U diff --git a/firmware/test_app/main/test_evidence_store.cpp b/firmware/test_app/main/test_evidence_store.cpp new file mode 100644 index 0000000..2de29ca --- /dev/null +++ b/firmware/test_app/main/test_evidence_store.cpp @@ -0,0 +1,231 @@ +/** + * @file test_evidence_store.cpp + * @brief Unit tests for v3.2.0 EvidenceStore module + * + * Tests evidence preservation, hash chain integrity, and circular overflow. + */ + +#include "unity.h" + +#include "forensics/evidence_store.hpp" + +using namespace gridshield; +using namespace gridshield::forensics; + +// ============================================================================ +// EvidenceStore Tests +// ============================================================================ + +static SensorSnapshot make_sensor(uint32_t energy, uint32_t voltage, uint32_t current) +{ + SensorSnapshot s; + s.energy_wh = energy; + s.voltage_mv = voltage; + s.current_ma = current; + return s; +} + +static void test_evidence_store_basic() +{ + EvidenceStore store; + TEST_ASSERT_EQUAL(0, store.evidence_count()); + + auto result = store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + "Panel opened"); + TEST_ASSERT_TRUE(result.is_ok()); + TEST_ASSERT_EQUAL(1, store.evidence_count()); + + auto snap = store.get_evidence(0); + TEST_ASSERT_TRUE(snap.is_ok()); + TEST_ASSERT_EQUAL(1000, snap.value().timestamp); + TEST_ASSERT_EQUAL(static_cast(SecurityEventType::CasingOpened), + static_cast(snap.value().event_type)); + TEST_ASSERT_EQUAL(500, snap.value().sensors.energy_wh); + TEST_ASSERT_EQUAL_STRING("Panel opened", snap.value().notes); +} + +static void test_evidence_store_multiple() +{ + EvidenceStore store; + store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + nullptr); + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + make_sensor(1500, 230000, 6000), + nullptr); + store.preserve(SecurityEventType::SignatureVerifyFailed, + SecurityEventSeverity::Critical, + SourceLayer::Network, + 3000, + make_sensor(600, 219000, 2500), + nullptr); + + TEST_ASSERT_EQUAL(3, store.evidence_count()); + + auto latest = store.latest(); + TEST_ASSERT_TRUE(latest.is_ok()); + TEST_ASSERT_EQUAL(3000, latest.value().timestamp); +} + +static void test_evidence_store_hash_populated() +{ + EvidenceStore store; + store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + nullptr); + + auto snap = store.get_evidence(0); + TEST_ASSERT_TRUE(snap.is_ok()); + + // Hash should be non-zero + bool all_zero = true; + for (size_t i = 0; i < EVIDENCE_HASH_SIZE; ++i) { + if (snap.value().hash[i] != 0) { + all_zero = false; + break; + } + } + TEST_ASSERT_FALSE(all_zero); + + // First entry's prev_hash should be all zeros + bool prev_zero = true; + for (size_t i = 0; i < EVIDENCE_HASH_SIZE; ++i) { + if (snap.value().prev_hash[i] != 0) { + prev_zero = false; + break; + } + } + TEST_ASSERT_TRUE(prev_zero); +} + +static void test_evidence_store_hash_chain_link() +{ + EvidenceStore store; + store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + nullptr); + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + make_sensor(1500, 230000, 6000), + nullptr); + + auto first = store.get_evidence(0); + auto second = store.get_evidence(1); + TEST_ASSERT_TRUE(first.is_ok()); + TEST_ASSERT_TRUE(second.is_ok()); + + // Second entry's prev_hash should equal first entry's hash + TEST_ASSERT_EQUAL_UINT8_ARRAY(first.value().hash, second.value().prev_hash, EVIDENCE_HASH_SIZE); +} + +static void test_evidence_store_verify_chain() +{ + EvidenceStore store; + + // Store 5 snapshots + for (size_t i = 0; i < 5; ++i) { + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + static_cast(1000 + i * 1000), + make_sensor(static_cast(500 + i * 100), 220000, 2000), + nullptr); + } + + TEST_ASSERT_EQUAL(5, store.evidence_count()); + TEST_ASSERT_TRUE(store.verify_chain()); +} + +static void test_evidence_store_circular_overflow() +{ + EvidenceStore store; + + // Overflow: write capacity + 3 + for (size_t i = 0; i < EVIDENCE_STORE_CAPACITY + 3; ++i) { + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Low, + SourceLayer::Analytics, + static_cast(1000 + i * 100), + make_sensor(static_cast(500 + i), 220000, 2000), + nullptr); + } + + TEST_ASSERT_EQUAL(EVIDENCE_STORE_CAPACITY, store.evidence_count()); + + // Oldest should be the 4th entry (first 3 overwritten) + auto oldest = store.get_evidence(0); + TEST_ASSERT_TRUE(oldest.is_ok()); + TEST_ASSERT_EQUAL(1300, oldest.value().timestamp); // 1000 + 3*100 + + // Latest should be the last written + auto latest = store.latest(); + TEST_ASSERT_TRUE(latest.is_ok()); + // 1000 + (32+2)*100 = 1000 + 3400 = 4400 + TEST_ASSERT_EQUAL(4400, latest.value().timestamp); +} + +static void test_evidence_store_latest_empty() +{ + EvidenceStore store; + auto result = store.latest(); + TEST_ASSERT_TRUE(result.is_error()); +} + +static void test_evidence_store_sequence_counter() +{ + EvidenceStore store; + store.preserve(SecurityEventType::CasingOpened, + SecurityEventSeverity::High, + SourceLayer::Physical, + 1000, + make_sensor(500, 220000, 2000), + nullptr); + store.preserve(SecurityEventType::AnomalyDetected, + SecurityEventSeverity::Medium, + SourceLayer::Analytics, + 2000, + make_sensor(600, 221000, 2100), + nullptr); + + auto first = store.get_evidence(0); + auto second = store.get_evidence(1); + TEST_ASSERT_TRUE(first.is_ok()); + TEST_ASSERT_TRUE(second.is_ok()); + + TEST_ASSERT_EQUAL(0, first.value().sequence); + TEST_ASSERT_EQUAL(1, second.value().sequence); +} + +// ============================================================================ +// TEST SUITE ENTRY POINT +// ============================================================================ + +extern "C" void test_evidence_store_suite(void) +{ + RUN_TEST(test_evidence_store_basic); + RUN_TEST(test_evidence_store_multiple); + RUN_TEST(test_evidence_store_hash_populated); + RUN_TEST(test_evidence_store_hash_chain_link); + RUN_TEST(test_evidence_store_verify_chain); + RUN_TEST(test_evidence_store_circular_overflow); + RUN_TEST(test_evidence_store_latest_empty); + RUN_TEST(test_evidence_store_sequence_counter); +} diff --git a/firmware/test_app/main/test_main.cpp b/firmware/test_app/main/test_main.cpp index cc14694..bbcefee 100644 --- a/firmware/test_app/main/test_main.cpp +++ b/firmware/test_app/main/test_main.cpp @@ -30,6 +30,7 @@ extern "C" void test_mqtt_suite(void); extern "C" void test_sensors_suite(void); extern "C" void test_ota_power_suite(void); extern "C" void test_forensics_suite(void); +extern "C" void test_evidence_store_suite(void); extern "C" void app_main(void) { @@ -63,6 +64,7 @@ extern "C" void app_main(void) test_sensors_suite(); test_ota_power_suite(); test_forensics_suite(); + test_evidence_store_suite(); int failures = UNITY_END(); diff --git a/frontend/src/api.js b/frontend/src/api.js index 91ee114..958638b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -54,3 +54,72 @@ export function getAnomalies({ meterId, limit = 50 } = {}) { const qs = params.toString(); return request(`/anomalies${qs ? '?' + qs : ''}`); } + +// ============================================================================ +// Meter CRUD +// ============================================================================ + +/** List all registered meters */ +export function getMeters({ status } = {}) { + const params = new URLSearchParams(); + if (status) params.set('status', status); + const qs = params.toString(); + return request(`/meters${qs ? '?' + qs : ''}`); +} + +/** Register a new meter */ +export function createMeter(data) { + return request('/meters', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** Update meter info */ +export function updateMeter(meterId, data) { + return request(`/meters/${meterId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }); +} + +/** Delete a meter */ +export function deleteMeter(meterId) { + return fetch(`${BASE}/meters/${meterId}`, { method: 'DELETE' }); +} + +/** Get meter statistics */ +export function getMeterStats(meterId) { + return request(`/meters/${meterId}/stats`); +} + +// ============================================================================ +// CSV Export โ€” triggers download +// ============================================================================ + +function triggerDownload(url, fallbackName) { + const a = document.createElement('a'); + a.href = `${BASE}${url}`; + a.download = fallbackName; + document.body.appendChild(a); + a.click(); + a.remove(); +} + +/** Export readings as CSV */ +export function exportReadings(meterId) { + const params = meterId != null ? `?meter_id=${meterId}` : ''; + triggerDownload(`/export/readings${params}`, 'gridshield_readings.csv'); +} + +/** Export alerts as CSV */ +export function exportAlerts(meterId) { + const params = meterId != null ? `?meter_id=${meterId}` : ''; + triggerDownload(`/export/alerts${params}`, 'gridshield_alerts.csv'); +} + +/** Export anomalies as CSV */ +export function exportAnomalies(meterId) { + const params = meterId != null ? `?meter_id=${meterId}` : ''; + triggerDownload(`/export/anomalies${params}`, 'gridshield_anomalies.csv'); +} diff --git a/frontend/src/pages/alerts.js b/frontend/src/pages/alerts.js index 8a40c06..8afa000 100644 --- a/frontend/src/pages/alerts.js +++ b/frontend/src/pages/alerts.js @@ -3,52 +3,52 @@ * Tamper alert management with filtering and acknowledge action */ -import { getAlerts, acknowledgeAlert } from '../api.js'; +import { getAlerts, acknowledgeAlert, exportAlerts } from '../api.js'; const TAMPER_ICONS = { - CasingOpened: '๐Ÿ“ฆ', - MagneticInterference: '๐Ÿงฒ', - PowerCutAttempt: '๐Ÿ”Œ', - PhysicalShock: '๐Ÿ’ฅ', - VibrationDetected: '๐Ÿ“ณ', - TemperatureAnomaly: '๐ŸŒก๏ธ', + CasingOpened: '๐Ÿ“ฆ', + MagneticInterference: '๐Ÿงฒ', + PowerCutAttempt: '๐Ÿ”Œ', + PhysicalShock: '๐Ÿ’ฅ', + VibrationDetected: '๐Ÿ“ณ', + TemperatureAnomaly: '๐ŸŒก๏ธ', }; function severityBadge(sev) { - const map = { - 4: ['CRITICAL', 'badge-critical'], - 3: ['HIGH', 'badge-high'], - 2: ['MEDIUM', 'badge-medium'], - 1: ['LOW', 'badge-low'], - 0: ['INFO', 'badge-info'], - }; - const [label, cls] = map[sev] || map[0]; - return `${label}`; + const map = { + 4: ['CRITICAL', 'badge-critical'], + 3: ['HIGH', 'badge-high'], + 2: ['MEDIUM', 'badge-medium'], + 1: ['LOW', 'badge-low'], + 0: ['INFO', 'badge-info'], + }; + const [label, cls] = map[sev] || map[0]; + return `${label}`; } function formatTime(ts) { - return new Date(ts).toLocaleString([], { - month: 'short', day: 'numeric', - hour: '2-digit', minute: '2-digit', - }); + return new Date(ts).toLocaleString([], { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); } function formatMeterId(id) { - return id.toString(16).toUpperCase().slice(-8); + return id.toString(16).toUpperCase().slice(-8); } export default async function renderAlerts(container) { - let filter = 'all'; + let filter = 'all'; - async function refresh() { - const params = {}; - if (filter === 'unacknowledged') params.acknowledged = false; - if (filter === 'acknowledged') params.acknowledged = true; - params.limit = 100; + async function refresh() { + const params = {}; + if (filter === 'unacknowledged') params.acknowledged = false; + if (filter === 'acknowledged') params.acknowledged = true; + params.limit = 100; - const alerts = await getAlerts(params); + const alerts = await getAlerts(params); - container.innerHTML = ` + container.innerHTML = `
@@ -94,13 +95,13 @@ export default async function renderAlerts(container) { ${severityBadge(a.severity)} ${a.acknowledged - ? 'Acknowledged' - : 'โณ Pending'} + ? 'Acknowledged' + : 'โณ Pending'} ${a.acknowledged - ? 'โ€”' - : ``} + ? 'โ€”' + : ``} `).join('')} @@ -111,29 +112,34 @@ export default async function renderAlerts(container) {
`; - // Bind filter - document.getElementById('alert-filter')?.addEventListener('change', (e) => { - filter = e.target.value; - refresh(); - }); + // Bind filter + document.getElementById('alert-filter')?.addEventListener('change', (e) => { + filter = e.target.value; + refresh(); + }); - // Bind acknowledge buttons - container.querySelectorAll('[data-ack-id]').forEach(btn => { - btn.addEventListener('click', async (e) => { - const id = e.target.dataset.ackId; - e.target.disabled = true; - e.target.textContent = '...'; - try { - await acknowledgeAlert(id); - await refresh(); - } catch (err) { - alert('Failed to acknowledge: ' + err.message); - e.target.disabled = false; - e.target.textContent = 'Acknowledge'; - } - }); - }); - } + // Export button + document.getElementById('export-alerts-btn')?.addEventListener('click', () => { + exportAlerts(); + }); + + // Bind acknowledge buttons + container.querySelectorAll('[data-ack-id]').forEach(btn => { + btn.addEventListener('click', async (e) => { + const id = e.target.dataset.ackId; + e.target.disabled = true; + e.target.textContent = '...'; + try { + await acknowledgeAlert(id); + await refresh(); + } catch (err) { + alert('Failed to acknowledge: ' + err.message); + e.target.disabled = false; + e.target.textContent = 'Acknowledge'; + } + }); + }); + } - await refresh(); + await refresh(); } diff --git a/frontend/src/pages/anomalies.js b/frontend/src/pages/anomalies.js index fcb53ff..7f9535d 100644 --- a/frontend/src/pages/anomalies.js +++ b/frontend/src/pages/anomalies.js @@ -3,23 +3,23 @@ * Anomaly detection logs with deviation visualization */ -import { getAnomalies } from '../api.js'; +import { getAnomalies, exportAnomalies } from '../api.js'; function severityBadge(sev) { - const s = (sev || '').toLowerCase(); - if (s === 'critical') return 'CRITICAL'; - if (s === 'high') return 'HIGH'; - if (s === 'medium') return 'MEDIUM'; - return 'LOW'; + const s = (sev || '').toLowerCase(); + if (s === 'critical') return 'CRITICAL'; + if (s === 'high') return 'HIGH'; + if (s === 'medium') return 'MEDIUM'; + return 'LOW'; } function deviationBar(pct) { - const clamped = Math.min(Math.abs(pct), 100); - let color = 'var(--color-green)'; - if (clamped >= 80) color = 'var(--color-red)'; - else if (clamped >= 50) color = 'var(--color-orange)'; - else if (clamped >= 30) color = 'var(--color-amber)'; - return ` + const clamped = Math.min(Math.abs(pct), 100); + let color = 'var(--color-green)'; + if (clamped >= 80) color = 'var(--color-red)'; + else if (clamped >= 50) color = 'var(--color-orange)'; + else if (clamped >= 30) color = 'var(--color-amber)'; + return `
@@ -30,28 +30,28 @@ function deviationBar(pct) { } function formatTime(ts) { - return new Date(ts).toLocaleString([], { - month: 'short', day: 'numeric', - hour: '2-digit', minute: '2-digit', - }); + return new Date(ts).toLocaleString([], { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); } function formatMeterId(id) { - return id.toString(16).toUpperCase().slice(-8); + return id.toString(16).toUpperCase().slice(-8); } export default async function renderAnomalies(container) { - let typeFilter = 'all'; + let typeFilter = 'all'; - async function refresh() { - const anomalies = await getAnomalies({ limit: 100 }); + async function refresh() { + const anomalies = await getAnomalies({ limit: 100 }); - const types = [...new Set(anomalies.map(a => a.anomaly_type))]; - const filtered = typeFilter === 'all' - ? anomalies - : anomalies.filter(a => a.anomaly_type === typeFilter); + const types = [...new Set(anomalies.map(a => a.anomaly_type))]; + const filtered = typeFilter === 'all' + ? anomalies + : anomalies.filter(a => a.anomaly_type === typeFilter); - container.innerHTML = ` + container.innerHTML = `
@@ -108,11 +109,15 @@ export default async function renderAnomalies(container) {
`; - document.getElementById('anomaly-type-filter')?.addEventListener('change', (e) => { - typeFilter = e.target.value; - refresh(); - }); - } + document.getElementById('anomaly-type-filter')?.addEventListener('change', (e) => { + typeFilter = e.target.value; + refresh(); + }); + + document.getElementById('export-anomalies-btn')?.addEventListener('click', () => { + exportAnomalies(); + }); + } - await refresh(); + await refresh(); } diff --git a/frontend/src/pages/dashboard.js b/frontend/src/pages/dashboard.js index 824d0bd..4c21718 100644 --- a/frontend/src/pages/dashboard.js +++ b/frontend/src/pages/dashboard.js @@ -3,36 +3,36 @@ * KPI cards + energy chart + recent alerts */ -import { getStatus, getReadings, getAlerts } from '../api.js'; +import { getStatus, getReadings, getAlerts, exportReadings } from '../api.js'; import { createLineChart } from '../components/chart.js'; /** Helper: format timestamp */ function timeAgo(ts) { - const diff = Date.now() - new Date(ts).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - return `${Math.floor(hrs / 24)}d ago`; + const diff = Date.now() - new Date(ts).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; } function severityColor(sev) { - if (sev >= 3) return 'var(--color-red)'; - if (sev >= 2) return 'var(--color-orange)'; - if (sev >= 1) return 'var(--color-amber)'; - return 'var(--color-green)'; + if (sev >= 3) return 'var(--color-red)'; + if (sev >= 2) return 'var(--color-orange)'; + if (sev >= 1) return 'var(--color-amber)'; + return 'var(--color-green)'; } export default async function renderDashboard(container) { - // Fetch data in parallel - const [status, readings, alerts] = await Promise.all([ - getStatus(), - getReadings({ limit: 100 }), - getAlerts({ limit: 10 }), - ]); - - container.innerHTML = ` + // Fetch data in parallel + const [status, readings, alerts] = await Promise.all([ + getStatus(), + getReadings({ limit: 100 }), + getAlerts({ limit: 10 }), + ]); + + container.innerHTML = `