"""Metriczone AF: queue a command, accept NEF callbacks, then dispatch actual UE SMS.""" import asyncio import json import os import time import uuid from contextlib import asynccontextmanager import httpx from fastapi import FastAPI, Header, HTTPException from pydantic import BaseModel, ConfigDict, Field from backend.models import SMS from backend.teaching import Store, Events, authorize, service_error class Notification(BaseModel): model_config = ConfigDict(extra="forbid") id: str = Field(pattern=r"^[0-9a-f]{32}$") subscription_id: str = Field(pattern=r"^[0-9a-f]{32}$") command_id: str = Field(pattern=r"^[0-9a-f]{32}$") recipient: str external_id: str available: bool observed_at: float evidence: dict attempts: int class Application: def __init__(self,store,client,backend,nef): self.store,self.client,self.backend,self.nef = store,client,backend,nef self.events = Events(store,client,backend) # Same message ID and submission time are reused after an interrupted dispatch. for command in store.all("commands"): if command["phase"]=="sending": command["phase"]="notified" store.put("commands",command) if self.current().get("id")==command["id"]: store.put("settings",command,"current") def current(self): return self.store.get("settings","current") or {"phase":"idle"} def save(self,command): value=json.dumps(command) with self.store.connection() as db: db.executemany("INSERT OR REPLACE INTO items VALUES (?,?,?)",[("commands",command["id"],value),("settings","current",value)]) async def event(self,src,dst,name,command,explain): await self.events.emit(src,dst,name,{"command_id":command.get("id"),"recipient":command.get("recipient"),"demo":command},explain) async def queue(self,request): if self.current()["phase"] in ("queued","notified","sending"): raise HTTPException(409,"A command is active. Reset the demo before queueing another.") await self.cancel_subscription(self.current()) command={"id":uuid.uuid4().hex,**request.model_dump(),"phase":"queued","queued_at":time.time(),"external_id":"meter-"+request.recipient} self.save(command) await self.event("af","af","Command queued",command,"The Metriczone application queues text until its target device becomes available.") try: response=await self.client.post(self.nef+"/teaching/v1/subscriptions",json={"external_id":command["external_id"],"sender":command["sender"],"command_id":command["id"]}, headers={"X-AF-Key":os.getenv("NEF_AF_TOKEN","local-metriczone-af")},timeout=15) response.raise_for_status() sub=response.json() except (httpx.HTTPError,ValueError) as error: command.update(phase="failed",error=service_error(error,"NEF adapter"));self.save(command) await self.event("observer","af","Subscription failed",command,"The application could not create its NEF teaching subscription.") raise HTTPException(503,command["error"]) from error command=self.store.get("commands",command["id"]) command["subscription_id"]=sub["id"] self.save(command) return command async def notify(self,notification): command=self.store.get("commands",notification.command_id) if not command or notification.recipient!=command["recipient"] or notification.external_id!=command["external_id"]: raise HTTPException(404,"No matching queued command") if not notification.available or not notification.evidence.get("available"): raise HTTPException(422,"The notification does not confirm an available device") if command.get("subscription_id") and command["subscription_id"]!=notification.subscription_id: raise HTTPException(422,"The callback belongs to a different subscription") if command["phase"] not in ("queued","notified") or command.get("notification_id"): return {"accepted":True,"duplicate":True} command.update(notification_id=notification.id,subscription_id=notification.subscription_id,observation=notification.evidence,phase="notified") self.save(command) await self.event("af","nef","Notification accepted",command,"The application saves the callback once. Repeated notifications cannot queue a second SMS.") return {"accepted":True} async def cancel_subscription(self,command): if not command.get("subscription_id"):return response=await self.client.delete(self.nef+"/teaching/v1/subscriptions/"+command["subscription_id"], headers={"X-AF-Key":os.getenv("NEF_AF_TOKEN","local-metriczone-af")},timeout=10) response.raise_for_status() async def reset(self): command=self.current() if command["phase"]=="sending":raise HTTPException(409,"Wait for the in-flight SMS receipt before resetting") # Cancel locally before awaiting NEF, so the worker cannot dispatch during reset. if command.get("id"): command["phase"]="cancelled";self.save(command) await self.cancel_subscription(command) if command.get("id"): command["phase"]="cancelled";self.store.put("commands",command) idle={"phase":"idle"} self.store.put("settings",idle,"current") await self.event("af","nef","Demo reset",idle,"The teaching subscription is cancelled. The ordinary SMS simulator remains available.") return idle async def dispatch_once(self): command=self.current() if command["phase"]!="notified":return command.setdefault("dispatch_at",time.time()) command["phase"]="sending";self.save(command) await self.event("af","observer","Dispatch queued SMS",command,"The notified application asks the lab's sender UE to send its queued command through the existing SMS data path.") try: response=await self.client.post(self.backend+"/api/nef/dispatch",json={"id":command["id"],"sender":command["sender"],"recipient":command["recipient"],"text":command["text"],"sent_at":command["dispatch_at"],"notification_id":command["notification_id"],"subscription_id":command["subscription_id"]}, headers={"X-Teaching-Token":os.getenv("TEACHING_TOKEN","local-nef-teaching")},timeout=150) if response.status_code==409: command["phase"]="notified";self.save(command);return response.raise_for_status() receipt=response.json() if not isinstance(receipt,dict) or receipt.get("status")!="delivered" or any(receipt.get(key)!=command[key] for key in ("id","sender","recipient","text")) or not isinstance(receipt.get("delivered_at"),(int,float)): raise ValueError("Dispatch did not return a matching destination delivery receipt") except (httpx.HTTPError,ValueError) as error: command.update(phase="failed",error=service_error(error,"SMS dispatch"));self.save(command) await self.event("observer","af","Command delivery failed",command,"SMS dispatch failed; the application does not report a delivered command.") return command.update(phase="delivered",receipt=receipt);self.save(command) await self.event("observer","af","Command delivery confirmed",command,"The destination UE saved the command and returned the matching SMS receipt.") async def run(self): while True: await self.dispatch_once() await asyncio.sleep(.5) def create_app(path=None,transport=None): store=Store(path or os.getenv("TEACHING_DB","/data/af.sqlite3")) @asynccontextmanager async def lifespan(app): async with httpx.AsyncClient(transport=transport) as client: app.state.af=Application(store,client,os.getenv("BACKEND_URL","http://172.24.0.30:8000"),os.getenv("NEF_URL","http://172.24.0.2:8000")) app.state.actions=asyncio.Lock() tasks=[asyncio.create_task(app.state.af.run()),asyncio.create_task(app.state.af.events.run())] try:yield finally: for task in tasks:task.cancel() await asyncio.gather(*tasks,return_exceptions=True) app=FastAPI(title="Metriczone demo Application Function",lifespan=lifespan) @app.get("/health") async def health():return {"status":"ok","teaching":True} @app.get("/teaching/v1/demo") async def state(x_teaching_token:str|None=Header(default=None)): authorize(x_teaching_token);return app.state.af.current() @app.post("/teaching/v1/demo") async def queue(request:SMS,x_teaching_token:str|None=Header(default=None)): authorize(x_teaching_token) async with app.state.actions: return await app.state.af.queue(request) @app.post("/teaching/v1/reset") async def reset(x_teaching_token:str|None=Header(default=None)): authorize(x_teaching_token) async with app.state.actions: return await app.state.af.reset() @app.post("/teaching/v1/notifications",status_code=202) async def notify(request:Notification,x_teaching_token:str|None=Header(default=None)): authorize(x_teaching_token);return await app.state.af.notify(request) return app