diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d67558 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/manuals/ diff --git a/README.md b/README.md index 3cabd35..de238b0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,17 @@ # slmm -Dashboard for talking to RION SLM +Standalone NL43 addon module (keep separate from the SFM/terra-view codebase). + +Run the addon API: +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8100 +``` + +Endpoints: +- `GET /health` +- `GET /api/nl43/{unit_id}/config` +- `PUT /api/nl43/{unit_id}/config` +- `GET /api/nl43/{unit_id}/status` +- `POST /api/nl43/{unit_id}/status` + +Use `app/services.py` to wire in the TCP connector and call `persist_snapshot` with parsed DOD/DRD data. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..147ff99 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# SLMM addon package for NL43 integration. diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..91c5630 --- /dev/null +++ b/app/database.py @@ -0,0 +1,27 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +# Ensure data directory exists for the SLMM addon +os.makedirs("data", exist_ok=True) + +SQLALCHEMY_DATABASE_URL = "sqlite:///./data/slmm.db" + +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + """Dependency for database sessions.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_db_session(): + """Get a database session directly (not as a dependency).""" + return SessionLocal() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8e0c92e --- /dev/null +++ b/app/main.py @@ -0,0 +1,36 @@ +import os +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.database import Base, engine +from app import routers + +# Ensure database tables exist for the addon +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title="SLMM NL43 Addon", + description="Standalone module for NL43 configuration and status APIs", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(routers.router) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("app.main:app", host="0.0.0.0", port=int(os.getenv("PORT", "8100")), reload=True) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..5c9d52d --- /dev/null +++ b/app/models.py @@ -0,0 +1,39 @@ +from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text +from datetime import datetime +from app.database import Base + + +class NL43Config(Base): + """ + NL43 connection/config metadata for the standalone SLMM addon. + """ + + __tablename__ = "nl43_config" + + unit_id = Column(String, primary_key=True, index=True) + tcp_port = Column(Integer, default=80) # NL43 TCP control port (via RX55) + tcp_enabled = Column(Boolean, default=True) + ftp_enabled = Column(Boolean, default=False) + web_enabled = Column(Boolean, default=False) + + +class NL43Status(Base): + """ + Latest NL43 status snapshot for quick dashboard/API access. + """ + + __tablename__ = "nl43_status" + + unit_id = Column(String, primary_key=True, index=True) + last_seen = Column(DateTime, default=datetime.utcnow) + measurement_state = Column(String, default="unknown") # Measure/Stop + lp = Column(String, nullable=True) + leq = Column(String, nullable=True) + lmax = Column(String, nullable=True) + lmin = Column(String, nullable=True) + lpeak = Column(String, nullable=True) + battery_level = Column(String, nullable=True) + power_source = Column(String, nullable=True) + sd_remaining_mb = Column(String, nullable=True) + sd_free_ratio = Column(String, nullable=True) + raw_payload = Column(Text, nullable=True) diff --git a/app/routers.py b/app/routers.py new file mode 100644 index 0000000..612920a --- /dev/null +++ b/app/routers.py @@ -0,0 +1,124 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime +from pydantic import BaseModel + +from app.database import get_db +from app.models import NL43Config, NL43Status + +router = APIRouter(prefix="/api/nl43", tags=["nl43"]) + + +class ConfigPayload(BaseModel): + tcp_port: int | None = None + tcp_enabled: bool | None = None + ftp_enabled: bool | None = None + web_enabled: bool | None = None + + +@router.get("/{unit_id}/config") +def get_config(unit_id: str, db: Session = Depends(get_db)): + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if not cfg: + raise HTTPException(status_code=404, detail="NL43 config not found") + return { + "unit_id": unit_id, + "tcp_port": cfg.tcp_port, + "tcp_enabled": cfg.tcp_enabled, + "ftp_enabled": cfg.ftp_enabled, + "web_enabled": cfg.web_enabled, + } + + +@router.put("/{unit_id}/config") +def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depends(get_db)): + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if not cfg: + cfg = NL43Config(unit_id=unit_id) + db.add(cfg) + + if payload.tcp_port is not None: + cfg.tcp_port = payload.tcp_port + if payload.tcp_enabled is not None: + cfg.tcp_enabled = payload.tcp_enabled + if payload.ftp_enabled is not None: + cfg.ftp_enabled = payload.ftp_enabled + if payload.web_enabled is not None: + cfg.web_enabled = payload.web_enabled + + db.commit() + db.refresh(cfg) + return { + "unit_id": unit_id, + "tcp_port": cfg.tcp_port, + "tcp_enabled": cfg.tcp_enabled, + "ftp_enabled": cfg.ftp_enabled, + "web_enabled": cfg.web_enabled, + } + + +@router.get("/{unit_id}/status") +def get_status(unit_id: str, db: Session = Depends(get_db)): + status = db.query(NL43Status).filter_by(unit_id=unit_id).first() + if not status: + raise HTTPException(status_code=404, detail="No NL43 status recorded") + return { + "unit_id": unit_id, + "last_seen": status.last_seen.isoformat() if status.last_seen else None, + "measurement_state": status.measurement_state, + "lp": status.lp, + "leq": status.leq, + "lmax": status.lmax, + "lmin": status.lmin, + "lpeak": status.lpeak, + "battery_level": status.battery_level, + "power_source": status.power_source, + "sd_remaining_mb": status.sd_remaining_mb, + "sd_free_ratio": status.sd_free_ratio, + "raw_payload": status.raw_payload, + } + + +class StatusPayload(BaseModel): + measurement_state: str | None = None + lp: str | None = None + leq: str | None = None + lmax: str | None = None + lmin: str | None = None + lpeak: str | None = None + battery_level: str | None = None + power_source: str | None = None + sd_remaining_mb: str | None = None + sd_free_ratio: str | None = None + raw_payload: str | None = None + + +@router.post("/{unit_id}/status") +def upsert_status(unit_id: str, payload: StatusPayload, db: Session = Depends(get_db)): + status = db.query(NL43Status).filter_by(unit_id=unit_id).first() + if not status: + status = NL43Status(unit_id=unit_id) + db.add(status) + + status.last_seen = datetime.utcnow() + for field, value in payload.dict().items(): + if value is not None: + setattr(status, field, value) + + db.commit() + db.refresh(status) + return { + "unit_id": unit_id, + "last_seen": status.last_seen.isoformat(), + "measurement_state": status.measurement_state, + "lp": status.lp, + "leq": status.leq, + "lmax": status.lmax, + "lmin": status.lmin, + "lpeak": status.lpeak, + "battery_level": status.battery_level, + "power_source": status.power_source, + "sd_remaining_mb": status.sd_remaining_mb, + "sd_free_ratio": status.sd_free_ratio, + "raw_payload": status.raw_payload, + } diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000..dcb8fa1 --- /dev/null +++ b/app/services.py @@ -0,0 +1,55 @@ +""" +Placeholder for NL43 TCP connector. +Implement TCP session management, command serialization, and DOD/DRD parsing here, +then call persist_snapshot to store the latest values. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from app.database import get_db_session +from app.models import NL43Status + + +@dataclass +class NL43Snapshot: + unit_id: str + measurement_state: str = "unknown" + lp: Optional[str] = None + leq: Optional[str] = None + lmax: Optional[str] = None + lmin: Optional[str] = None + lpeak: Optional[str] = None + battery_level: Optional[str] = None + power_source: Optional[str] = None + sd_remaining_mb: Optional[str] = None + sd_free_ratio: Optional[str] = None + raw_payload: Optional[str] = None + + +def persist_snapshot(s: NL43Snapshot): + """Persist the latest snapshot for API/dashboard use.""" + db = get_db_session() + try: + row = db.query(NL43Status).filter_by(unit_id=s.unit_id).first() + if not row: + row = NL43Status(unit_id=s.unit_id) + db.add(row) + + row.last_seen = datetime.utcnow() + row.measurement_state = s.measurement_state + row.lp = s.lp + row.leq = s.leq + row.lmax = s.lmax + row.lmin = s.lmin + row.lpeak = s.lpeak + row.battery_level = s.battery_level + row.power_source = s.power_source + row.sd_remaining_mb = s.sd_remaining_mb + row.sd_free_ratio = s.sd_free_ratio + row.raw_payload = s.raw_payload + + db.commit() + finally: + db.close() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0a4249c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +sqlalchemy +pydantic