Merge pull request #12 from serversdwn/claude/dev-015sto5mf2MpPCE57TbNKtaF

v0.2.2-series4 endpoint added, dev branch set up at :1001
This commit is contained in:
serversdwn
2025-12-08 17:23:50 -05:00
committed by GitHub
8 changed files with 206 additions and 13 deletions

View File

@@ -1,3 +1,4 @@
import os
from fastapi import FastAPI, Request, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
@@ -13,11 +14,15 @@ from backend.models import IgnoredUnit
# Create database tables
Base.metadata.create_all(bind=engine)
# Read environment (development or production)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.2.2"
app = FastAPI(
title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status",
version="0.1.1"
version=VERSION
)
# Configure CORS
@@ -35,6 +40,24 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Setup Jinja2 templates
templates = Jinja2Templates(directory="templates")
# Add custom context processor to inject environment variable into all templates
@app.middleware("http")
async def add_environment_to_context(request: Request, call_next):
"""Middleware to add environment variable to request state"""
request.state.environment = ENVIRONMENT
response = await call_next(request)
return response
# Override TemplateResponse to include environment and version in context
original_template_response = templates.TemplateResponse
def custom_template_response(name, context=None, *args, **kwargs):
if context is None:
context = {}
context["environment"] = ENVIRONMENT
context["version"] = VERSION
return original_template_response(name, context, *args, **kwargs)
templates.TemplateResponse = custom_template_response
# Include API routers
app.include_router(roster.router)
app.include_router(units.router)

View File

@@ -202,7 +202,7 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"""
Permanently delete a unit from the database.
Checks both roster and emitters tables and deletes from any table where the unit exists.
Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists.
"""
deleted = False
@@ -218,7 +218,13 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
db.delete(emitter)
deleted = True
# If not found in either table, return error
# Try to delete from ignored_units table
ignored_unit = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
if ignored_unit:
db.delete(ignored_unit)
deleted = True
# If not found in any table, return error
if not deleted:
raise HTTPException(status_code=404, detail="Unit not found")

View File

@@ -10,6 +10,32 @@ from backend.models import Emitter
router = APIRouter()
# Helper function to detect unit type from unit ID
def detect_unit_type(unit_id: str) -> str:
"""
Automatically detect if a unit is Series 3 or Series 4 based on ID pattern.
Series 4 (Micromate) units have IDs starting with "UM" followed by digits (e.g., UM11719)
Series 3 units typically have other patterns
Returns:
"series4" if the unit ID matches Micromate pattern (UM#####)
"series3" otherwise
"""
if not unit_id:
return "unknown"
# Series 4 (Micromate) pattern: UM followed by digits
if unit_id.upper().startswith("UM") and len(unit_id) > 2:
# Check if remaining characters after "UM" are digits
rest = unit_id[2:]
if rest.isdigit():
return "series4"
# Default to series3 for other patterns
return "series3"
# Pydantic schemas for request/response validation
class EmitterReport(BaseModel):
unit: str
@@ -138,11 +164,16 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
emitter.last_seen = ts
emitter.last_file = last_file
emitter.status = status
# Update unit_type if it was incorrectly classified
detected_type = detect_unit_type(uid)
if emitter.unit_type != detected_type:
emitter.unit_type = detected_type
else:
# Insert new
# Insert new - auto-detect unit type from ID
detected_type = detect_unit_type(uid)
emitter = Emitter(
id=uid,
unit_type="series3",
unit_type=detected_type,
last_seen=ts,
last_file=last_file,
status=status
@@ -159,3 +190,97 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
"units_processed": len(results),
"results": results
}
# series4 (Micromate) Standardized Heartbeat Schema
@router.post("/api/series4/heartbeat", status_code=200)
async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
"""
Accepts a full telemetry payload from the Series4 (Micromate) emitter.
Updates or inserts each unit into the database.
Expected payload:
{
"source": "series4_emitter",
"generated_at": "2025-12-04T20:01:00",
"units": [
{
"unit_id": "UM11719",
"type": "micromate",
"project_hint": "Clearwater - ECMS 57940",
"last_call": "2025-12-04T19:30:42",
"status": "OK",
"age_days": 0.04,
"age_hours": 0.9,
"mlg_path": "C:\\THORDATA\\..."
}
]
}
"""
payload = await request.json()
source = payload.get("source", "series4_emitter")
units = payload.get("units", [])
print("\n=== Series 4 Heartbeat ===")
print("Source:", source)
print("Units received:", len(units))
print("==========================\n")
results = []
for u in units:
uid = u.get("unit_id")
last_call_str = u.get("last_call")
status = u.get("status", "Unknown")
mlg_path = u.get("mlg_path")
project_hint = u.get("project_hint")
# Parse last_call timestamp
try:
if last_call_str:
ts = datetime.fromisoformat(last_call_str.replace("Z", "+00:00"))
else:
ts = None
except:
ts = None
# Pull from DB
emitter = db.query(Emitter).filter(Emitter.id == uid).first()
if emitter:
# Update existing
emitter.last_seen = ts
emitter.last_file = mlg_path
emitter.status = status
# Update unit_type if it was incorrectly classified
detected_type = detect_unit_type(uid)
if emitter.unit_type != detected_type:
emitter.unit_type = detected_type
# Optionally update notes with project hint if it exists
if project_hint and not emitter.notes:
emitter.notes = f"Project: {project_hint}"
else:
# Insert new - auto-detect unit type from ID
detected_type = detect_unit_type(uid)
notes = f"Project: {project_hint}" if project_hint else None
emitter = Emitter(
id=uid,
unit_type=detected_type,
last_seen=ts,
last_file=mlg_path,
status=status,
notes=notes
)
db.add(emitter)
results.append({"unit": uid, "status": status})
db.commit()
return {
"message": "Heartbeat processed",
"source": source,
"units_processed": len(results),
"results": results
}

View File

@@ -113,12 +113,12 @@ def emit_status_snapshot():
# Separate buckets for UI
active_units = {
uid: u for uid, u in units.items()
if not u["retired"] and u["deployed"]
if not u["retired"] and u["deployed"] and uid not in ignored
}
benched_units = {
uid: u for uid, u in units.items()
if not u["retired"] and not u["deployed"]
if not u["retired"] and not u["deployed"] and uid not in ignored
}
retired_units = {
@@ -140,7 +140,7 @@ def emit_status_snapshot():
"retired": retired_units,
"unknown": unknown_units,
"summary": {
"total": len(units),
"total": len(active_units) + len(benched_units),
"active": len(active_units),
"benched": len(benched_units),
"retired": len(retired_units),

View File

@@ -1,16 +1,35 @@
version: '3.8'
services:
# --- PRODUCTION ---
seismo-backend:
build: .
container_name: seismo-fleet-manager
ports:
- "8001:8001"
volumes:
# Persist SQLite database and photos
- ./data:/app/data
environment:
- PYTHONUNBUFFERED=1
- ENVIRONMENT=production
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# --- DEVELOPMENT ---
sfm-dev:
build: .
container_name: sfm-dev
ports:
- "1001:8001"
volumes:
- ./data-dev:/app/data
environment:
- PYTHONUNBUFFERED=1
- ENVIRONMENT=development
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
@@ -21,3 +40,4 @@ services:
volumes:
data:
data-dev:

7
sfm.code-workspace Normal file
View File

@@ -0,0 +1,7 @@
{
"folders": [
{
"path": ".."
}
]
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Seismo Fleet Manager{% endblock %}</title>
<title>{% if environment == 'development' %}[DEV] {% endif %}{% block title %}Seismo Fleet Manager{% endblock %}</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
@@ -68,7 +68,12 @@
Seismo<br>
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
</h1>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">v 0.2.1</p>
<div class="flex items-center justify-between mt-2">
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
{% if environment == 'development' %}
<span class="px-2 py-1 text-xs font-bold text-white bg-yellow-500 rounded">DEV</span>
{% endif %}
</div>
</div>
<!-- Navigation -->

View File

@@ -3,6 +3,13 @@
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
{% block content %}
{% if environment == 'development' %}
<div class="mb-4 p-4 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 text-yellow-700 dark:text-yellow-200 rounded">
<p class="font-bold">Development Environment</p>
<p class="text-sm">You are currently viewing the development version of Seismo Fleet Manager.</p>
</div>
{% endif %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>