File size: 12,804 Bytes
fa7bc29 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 | """BMS platform APIs. Supervised writes go through evaluate_dispatch()."""
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from backend.bms.command_writer import disable_supervised_writes, enable_supervised_writes, write_disabled_body, write_point
from backend.bms.connection_manager import get_connection_manager
from backend.middleware.request_id import current_request_id
from backend.services import platform_bms_service as bms
router = APIRouter(prefix="/api/platform/bms", tags=["BMS"])
safety_router = APIRouter(prefix="/api/platform", tags=["Platform"])
agents_router = APIRouter(prefix="/api", tags=["Agents"])
class ConnectRequest(BaseModel):
protocol: str = "bacnet"
host: str
port: int = 47808
building_id: Optional[str] = None
test_only: bool = False
class MappingRequest(BaseModel):
equipment_id: str
canonical_point: str
bms_point_id: str
direction: str = "READ"
safety_enabled: bool = True
class WriteEnableRequest(BaseModel):
confirm: bool = False
class DispatchApplyRequest(BaseModel):
opportunity_id: str
point_id: Optional[str] = None
equipment_id: Optional[str] = None
current_value: Optional[float] = None
target_value: Optional[float] = None
confidence: Optional[float] = None
decision: Optional[str] = "OPTIMIZE"
class SafetyEvaluateRequest(BaseModel):
opportunity_id: Optional[str] = None
current_value: Optional[float] = None
target_value: Optional[float] = None
confidence: Optional[float] = None
decision: Optional[str] = "OPTIMIZE"
@router.get("/status")
async def get_status():
return bms.bms_status()
@router.post("/connect")
async def connect(req: ConnectRequest):
mgr = get_connection_manager()
return mgr.connect(req.protocol, req.host, req.port, building_id=req.building_id, test_only=req.test_only)
@router.post("/disconnect")
async def disconnect():
return get_connection_manager().disconnect()
@router.post("/discover")
async def discover():
return get_connection_manager().discover()
@router.get("/devices")
async def devices():
rows = bms.list_devices()
return {"devices": rows, "count": len(rows)}
@router.get("/devices/{device_id}/points")
async def device_points(device_id: str):
pts = bms.list_points(device_id)
return {"points": pts, "count": len(pts)}
@router.get("/mappings")
async def get_mappings():
return {"mappings": bms.list_mappings(), "catalog": bms.catalog()}
@router.put("/mappings")
async def put_mappings(req: MappingRequest):
try:
row = bms.put_mapping(req.model_dump())
except ValueError as exc:
raise HTTPException(status_code=400, detail={"code": "MAPPING_INVALID", "message": str(exc)})
return row
@router.get("/telemetry")
async def telemetry():
return {"points": bms.mapped_telemetry()}
@router.get("/plant")
async def plant():
return bms.plant_overview()
@router.get("/stage-g/status")
async def stage_g_status(point_id: Optional[str] = None):
from backend.bms.stage_g import stage_g_status as _status
return _status(point_id)
@router.post("/write-enable")
async def write_enable(request: Request):
try:
payload = await request.json()
except Exception:
payload = {}
body = enable_supervised_writes(confirm=bool((payload or {}).get("confirm")))
if not body.get("enabled"):
raise HTTPException(status_code=409, detail=body)
return body
@router.post("/write-disable")
async def write_disable():
return disable_supervised_writes()
@router.post("/write")
async def write(payload: Dict[str, Any]):
from backend.services.hvac_safety_contract import evaluate_dispatch
from backend.services.platform_bms_service import platform_snapshot
snap = platform_snapshot()
tel = snap.get("telemetry") or {}
ctx = {
"action": "APPLY",
"opportunity_id": payload.get("opportunity_id") or payload.get("opportunity"),
"source": tel.get("source"),
"telemetry": {
"source": tel.get("source"),
"quality": tel.get("quality"),
"age_seconds": tel.get("ageSeconds"),
"raw": tel.get("status"),
},
"supervisory": {"decision": payload.get("decision") or "OPTIMIZE", "confidence": payload.get("confidence")},
"safety": {"status": snap.get("safety"), "passed": snap.get("safety") == "PASS"},
"current_value": payload.get("current_value"),
"target_value": payload.get("value") if payload.get("target_value") is None else payload.get("target_value"),
}
ok, reason, classified = evaluate_dispatch(ctx)
if not ok:
raise HTTPException(status_code=409, detail={**write_disabled_body(reason, classified.get("code") or "DISPATCH_BLOCKED"), "reason": reason})
outcome = write_point(str(payload.get("point_id") or ""), float(payload.get("value") or 0))
if not outcome.success:
raise HTTPException(status_code=409, detail={**write_disabled_body(outcome.message, outcome.code), **outcome.as_dict()})
return outcome.as_dict()
@safety_router.post("/commands/apply")
async def command_apply(req: DispatchApplyRequest):
from backend.agents.runtime.recommendation import recommend_and_maybe_apply
from backend.services.platform_bms_service import platform_snapshot
snap = platform_snapshot()
tel = snap.get("telemetry") or {}
result = recommend_and_maybe_apply(
opportunity=req.opportunity_id,
building_id=None,
point_id=req.point_id,
old_value=req.current_value,
new_value=req.target_value,
reason="SUPERVISED_APPLY",
user={},
mode="SUPERVISED",
context={
"source": tel.get("source"),
"telemetry": {
"source": tel.get("source"),
"quality": tel.get("quality"),
"age_seconds": tel.get("ageSeconds"),
"raw": tel.get("status"),
},
"supervisory": {"decision": req.decision or "OPTIMIZE", "confidence": req.confidence},
"safety": {"status": snap.get("safety"), "passed": snap.get("safety") == "PASS"},
"equipment_id": req.equipment_id,
"confidence": req.confidence,
},
)
if not result.get("allowed"):
raise HTTPException(
status_code=409,
detail={
"code": (result.get("classified") or {}).get("code") or "DISPATCH_BLOCKED",
"message": result.get("reason") or "Dispatch blocked.",
"command": result.get("command"),
},
)
return result
@safety_router.post("/commands/{command_id}/approve")
async def command_approve(command_id: str):
from backend.agents.runtime.approval import approve_command
ok, reason, cmd = approve_command(command_id)
if not ok:
raise HTTPException(status_code=409, detail={"code": reason, "message": reason, "command_id": command_id, "command": cmd})
return {"ok": True, "status": reason, "command_id": command_id, "command": cmd}
@safety_router.post("/commands/{command_id}/apply")
async def command_apply_existing(command_id: str):
"""Stage G: apply an existing APPROVED control_commands row (Safe RL / O*)."""
from backend.agents.runtime.apply import apply_setpoint
from backend.agents.runtime.command import get_command
from backend.bms.stage_g import point_allowed, prerequisites_ok
from backend.services.platform_bms_service import platform_snapshot
cmd = get_command(command_id)
if not cmd:
raise HTTPException(status_code=404, detail={"code": "NOT_FOUND", "message": "Command not found", "command_id": command_id})
status = (cmd.get("status") or "").upper()
if status != "APPROVED":
raise HTTPException(
status_code=409,
detail={"code": "NOT_APPROVED", "message": f"Command status is {status}; APPROVED required", "command": cmd},
)
point_id = str(cmd.get("point_id") or "")
if not point_allowed(point_id):
raise HTTPException(
status_code=409,
detail={"code": "STAGE_G_POINT_NOT_ALLOWED", "message": f"{point_id} not on Stage G allowlist", "command": cmd},
)
gate = prerequisites_ok(point_id)
if not gate.get("ok"):
raise HTTPException(
status_code=409,
detail={"code": "STAGE_G_PREREQS", "message": "Stage G prerequisites not met", "prerequisites": gate, "command": cmd},
)
new_value = cmd.get("new_value")
if new_value is None:
raise HTTPException(status_code=409, detail={"code": "MISSING_VALUES", "message": "new_value required", "command": cmd})
snap = platform_snapshot()
tel = snap.get("telemetry") or {}
context = {
"action": "APPLY",
"opportunity_id": cmd.get("opportunity"),
"point_id": point_id,
"old_value": cmd.get("old_value"),
"current_value": cmd.get("old_value"),
"new_value": float(new_value),
"target_value": float(new_value),
"approval_status": "APPROVED",
"mode": "SUPERVISED",
"source": tel.get("source"),
"telemetry": {
"source": tel.get("source"),
"quality": tel.get("quality"),
"age_seconds": tel.get("ageSeconds"),
"raw": tel.get("status"),
},
"supervisory": {"decision": "OPTIMIZE", "confidence": 0.99},
"safety": {"status": snap.get("safety"), "passed": snap.get("safety") == "PASS"},
"confidence": 0.99,
"decision": "OPTIMIZE",
"building_id": cmd.get("building_id"),
"equipment_id": cmd.get("equipment_id"),
"normalized": {"Indoor_Temp": None, "Occupancy": 0.5, "quality": tel.get("quality") or "GOOD"},
"schedule_hour": 12,
}
ok, reason = apply_setpoint(command_id, point_id, float(new_value), context)
if not ok:
raise HTTPException(
status_code=409,
detail={"code": reason, "message": reason, "command_id": command_id, "command": get_command(command_id)},
)
return {"ok": True, "status": reason, "command_id": command_id, "command": get_command(command_id)}
@safety_router.post("/commands/{command_id}/verify")
async def command_verify(command_id: str):
from backend.agents.runtime.verification import verify_command
ok, reason = verify_command(command_id)
if not ok:
raise HTTPException(status_code=409, detail={"code": reason, "message": reason, "command_id": command_id})
return {"ok": True, "status": reason, "command_id": command_id}
@safety_router.post("/commands/{command_id}/rollback")
async def command_rollback(command_id: str):
from backend.agents.runtime.verification import rollback_command
ok, reason = rollback_command(command_id)
if not ok:
raise HTTPException(status_code=409, detail={"code": reason, "message": reason, "command_id": command_id})
return {"ok": True, "status": reason, "command_id": command_id}
@safety_router.get("/dashboard/home")
async def dashboard_home():
from backend.services.dashboard_home_service import dashboard_home as _home
return _home()
@safety_router.get("/safety/evaluate")
@safety_router.post("/safety/evaluate")
async def safety_evaluate(req: Optional[SafetyEvaluateRequest] = None):
return bms.evaluate_safety(req.model_dump() if req else {})
@agents_router.get("/agents")
async def agent_center():
return {"groups": bms.agent_groups(), "request_id": current_request_id()}
@agents_router.get("/agents/{opportunity}/context")
async def agent_context(opportunity: str, equipment_id: Optional[str] = None, building_id: Optional[str] = None):
from backend.services.agent_telemetry_service import get_agent_context
from backend.services.opportunity_feature_catalog import catalog_for
try:
catalog_for(opportunity)
except KeyError:
raise HTTPException(status_code=404, detail={"code": "UNKNOWN_OPPORTUNITY", "message": opportunity})
return get_agent_context(opportunity, equipment_id, building_id)
@agents_router.get("/agents/{opportunity}/recommendation")
async def agent_recommendation(opportunity: str, equipment_id: Optional[str] = None, building_id: Optional[str] = None):
from backend.services.agent_recommendation_service import build_recommendation
from backend.services.opportunity_feature_catalog import catalog_for
try:
catalog_for(opportunity)
except KeyError:
raise HTTPException(status_code=404, detail={"code": "UNKNOWN_OPPORTUNITY", "message": opportunity})
return build_recommendation(opportunity, equipment_id, building_id)
|