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)