inital v4
This commit is contained in:
862
v4/server/app/main.py
Normal file
862
v4/server/app/main.py
Normal file
@@ -0,0 +1,862 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .config import Config
|
||||
from .connection_manager import ConnectionManager
|
||||
from .models import DeviceHello, DispatchRequest, DispatchResult, UpdateManifest
|
||||
from .store import store
|
||||
|
||||
app = FastAPI(title="NotifyPulse V4 Server", version="4.0.0-alpha")
|
||||
manager = ConnectionManager()
|
||||
_pwa_clients: dict[str, dict[str, Any]] = {}
|
||||
_pending_wallpapers: dict[str, dict[str, str]] = {}
|
||||
_pwa_overlay: dict[str, Any] = {"active": False, "image": None, "until_ms": 0}
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
|
||||
ALL_BLOCKS = [
|
||||
{"id": "notifications", "name": "Notifications", "description": "Server-dispatched notifications", "icon": "🔔"},
|
||||
{"id": "wallpaper", "name": "Wallpaper", "description": "Wallpaper command dispatch", "icon": "🖼️"},
|
||||
{"id": "overlay", "name": "Overlay", "description": "Overlay command dispatch", "icon": "✨"},
|
||||
{"id": "timer", "name": "Timer", "description": "Scheduled triggers (server-side in V4)", "icon": "⏱️"},
|
||||
{"id": "mobile_wallpaper", "name": "Mobile Wallpaper", "description": "PWA wallpaper receiver", "icon": "📱"},
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def require_token(x_api_token: str = Header(default="")) -> None:
|
||||
# Optional auth: if NP4_API_TOKEN is empty, routes are open.
|
||||
if Config.api_token and x_api_token != Config.api_token:
|
||||
raise HTTPException(status_code=401, detail="invalid API token")
|
||||
|
||||
|
||||
def _active_usecase_id() -> str:
|
||||
return str(store.get_settings().get("active_usecase", ""))
|
||||
|
||||
|
||||
def _active_usecase() -> dict[str, Any] | None:
|
||||
uid = _active_usecase_id()
|
||||
for uc in store.get_usecases():
|
||||
if uc.get("id") == uid:
|
||||
return uc
|
||||
return None
|
||||
|
||||
|
||||
def _set_active_usecase(uid: str) -> None:
|
||||
store.update_settings({"active_usecase": uid})
|
||||
|
||||
|
||||
def _parse_entry(line: str) -> dict[str, Any]:
|
||||
parts = [p.strip() for p in line.split("|", 1)]
|
||||
text = parts[0]
|
||||
weight = None
|
||||
trigger_time = None
|
||||
if len(parts) > 1:
|
||||
rhs = parts[1]
|
||||
m_pct = re.match(r"^(\d+(?:\.\d+)?)\s*%$", rhs)
|
||||
m_time = re.match(r"^([01]?\d|2[0-3]):([0-5]\d)$", rhs)
|
||||
if m_pct:
|
||||
weight = float(m_pct.group(1))
|
||||
elif m_time:
|
||||
trigger_time = f"{int(m_time.group(1)):02d}:{m_time.group(2)}"
|
||||
return {"text": text, "weight": weight, "trigger_time": trigger_time}
|
||||
|
||||
|
||||
def _img_to_datauri(path: Path) -> str | None:
|
||||
try:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime};base64,{data}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _legacy_log_item(msg: str, level: str = "info") -> dict[str, Any]:
|
||||
return {"time": utc_now_iso(), "msg": msg, "level": level}
|
||||
|
||||
|
||||
def _event_to_legacy_log(event: dict[str, Any]) -> dict[str, Any]:
|
||||
if "msg" in event:
|
||||
return {
|
||||
"time": event.get("time", utc_now_iso()),
|
||||
"msg": str(event.get("msg", "")),
|
||||
"level": str(event.get("level", "info")),
|
||||
}
|
||||
|
||||
et = str(event.get("event", "event"))
|
||||
level = "info"
|
||||
msg = et
|
||||
if et == "device_connected":
|
||||
msg = f"Client connected: {event.get('device_id', '?')} ({event.get('platform', 'unknown')})"
|
||||
elif et == "device_disconnected":
|
||||
msg = f"Client disconnected: {event.get('device_id', '?')}"
|
||||
elif et == "command_dispatched":
|
||||
msg = f"Dispatch {event.get('action', '?')} -> {event.get('delivered_count', 0)} client(s)"
|
||||
elif et == "command_ack":
|
||||
st = str(event.get("status", "ok"))
|
||||
detail = str(event.get("detail", "")).strip()
|
||||
msg = f"Ack {event.get('command_id', '?')} from {event.get('device_id', '?')}: {st}"
|
||||
if detail:
|
||||
msg += f" ({detail})"
|
||||
if st in {"error", "unsupported"}:
|
||||
level = "warn" if st == "unsupported" else "error"
|
||||
elif et == "client_event":
|
||||
msg = f"Client event {event.get('name', '?')} from {event.get('device_id', '?')}"
|
||||
elif et == "socket_error":
|
||||
msg = f"Socket error ({event.get('device_id', '?')}): {event.get('error', '')}"
|
||||
level = "error"
|
||||
elif et == "unknown_message":
|
||||
msg = f"Unknown message from {event.get('device_id', '?')}"
|
||||
level = "warn"
|
||||
return {"time": event.get("time", utc_now_iso()), "msg": msg, "level": level}
|
||||
|
||||
|
||||
async def _append_log(msg: str, level: str = "info") -> None:
|
||||
await manager.add_event(
|
||||
{
|
||||
"event": "legacy_log",
|
||||
"msg": msg,
|
||||
"level": level,
|
||||
"time": utc_now_iso(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _ensure_usecase_dirs(uid: str) -> None:
|
||||
base = Config.data_dir / "usecases" / uid
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
for sub in ("wallpapers", "overlay", "mobile"):
|
||||
(base / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _list_images(folder: Path) -> list[Path]:
|
||||
if not folder.exists():
|
||||
return []
|
||||
return [
|
||||
p
|
||||
for p in folder.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in _IMAGE_EXTS
|
||||
]
|
||||
|
||||
|
||||
def _pick_usecase_image(bucket: str) -> Path | None:
|
||||
uc = _active_usecase()
|
||||
if not uc:
|
||||
return None
|
||||
uid = str(uc.get("id", "")).strip()
|
||||
if not uid:
|
||||
return None
|
||||
files = _list_images(Config.data_dir / "usecases" / uid / bucket)
|
||||
if not files:
|
||||
return None
|
||||
return random.choice(files)
|
||||
|
||||
|
||||
def _pick_mobile_wallpapers() -> tuple[Path, Path] | None:
|
||||
uc = _active_usecase()
|
||||
if not uc:
|
||||
return None
|
||||
uid = str(uc.get("id", "")).strip()
|
||||
if not uid:
|
||||
return None
|
||||
files = _list_images(Config.data_dir / "usecases" / uid / "mobile")
|
||||
if not files:
|
||||
return None
|
||||
|
||||
lock_pref = [p for p in files if re.search(r"(lock|locks|screenlock)", p.stem, re.IGNORECASE)]
|
||||
bg_pref = [p for p in files if re.search(r"(home|bg|wall|back)", p.stem, re.IGNORECASE)]
|
||||
if lock_pref and bg_pref:
|
||||
lock = random.choice(lock_pref)
|
||||
bg_choices = [p for p in bg_pref if p != lock] or bg_pref
|
||||
bg = random.choice(bg_choices)
|
||||
return lock, bg
|
||||
|
||||
if len(files) >= 2:
|
||||
a, b = random.sample(files, 2)
|
||||
return a, b
|
||||
return files[0], files[0]
|
||||
|
||||
|
||||
def _build_asset_url(request: Request, uid: str, bucket: str, filename: str) -> str:
|
||||
base = str(request.base_url).rstrip("/")
|
||||
return f"{base}/api/v4/assets/usecases/{uid}/{bucket}/{filename}"
|
||||
|
||||
|
||||
def _queue_mobile_payload(payload: dict[str, str]) -> int:
|
||||
active_clients = [cid for cid, meta in _pwa_clients.items() if (time.time() - meta.get("last_seen", 0)) <= 90]
|
||||
if active_clients:
|
||||
for cid in active_clients:
|
||||
_pending_wallpapers[cid] = dict(payload)
|
||||
return len(active_clients)
|
||||
_pending_wallpapers["__broadcast__"] = dict(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def _pick_mobile_wallpaper_payload() -> dict[str, str] | None:
|
||||
uc = _active_usecase()
|
||||
if not uc:
|
||||
return None
|
||||
uid = str(uc.get("id", "")).strip()
|
||||
if not uid:
|
||||
return None
|
||||
chosen = _pick_mobile_wallpapers()
|
||||
if not chosen:
|
||||
return None
|
||||
lock_uri = _img_to_datauri(chosen[0])
|
||||
bg_uri = _img_to_datauri(chosen[1])
|
||||
if not lock_uri or not bg_uri:
|
||||
return None
|
||||
return {"lockscreen": lock_uri, "background": bg_uri}
|
||||
|
||||
|
||||
@app.get("/api/v4/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"service": "notifypulse-v4-server",
|
||||
"time": utc_now_iso(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v4/state")
|
||||
async def api_state() -> dict[str, Any]:
|
||||
devices = [d.model_dump() for d in await manager.list_devices()]
|
||||
logs = await manager.get_logs()
|
||||
return {
|
||||
"version": app.version,
|
||||
"settings": store.get_settings(),
|
||||
"usecases": store.get_usecases(),
|
||||
"connected_devices": devices,
|
||||
"events": logs,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v4/settings")
|
||||
async def get_settings() -> dict[str, Any]:
|
||||
return store.get_settings()
|
||||
|
||||
|
||||
@app.put("/api/v4/settings", dependencies=[Depends(require_token)])
|
||||
async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return store.update_settings(payload)
|
||||
|
||||
|
||||
@app.get("/api/v4/usecases")
|
||||
async def get_usecases() -> dict[str, Any]:
|
||||
return {"usecases": store.get_usecases()}
|
||||
|
||||
|
||||
class UsecasesPayload(BaseModel):
|
||||
usecases: list[dict[str, Any]]
|
||||
|
||||
|
||||
@app.put("/api/v4/usecases", dependencies=[Depends(require_token)])
|
||||
async def put_usecases(payload: UsecasesPayload) -> dict[str, Any]:
|
||||
updated = store.set_usecases(payload.usecases)
|
||||
await manager.dispatch_command(
|
||||
command_id=str(uuid.uuid4()),
|
||||
action="sync_settings",
|
||||
data={"settings": store.get_settings(), "usecases": updated},
|
||||
target_scope="all",
|
||||
device_ids=[],
|
||||
)
|
||||
return {"ok": True, "usecases": updated}
|
||||
|
||||
|
||||
@app.post("/api/v4/dispatch", dependencies=[Depends(require_token)])
|
||||
async def dispatch(payload: DispatchRequest) -> DispatchResult:
|
||||
command_id = str(uuid.uuid4())
|
||||
delivered, skipped = await manager.dispatch_command(
|
||||
command_id=command_id,
|
||||
action=payload.action,
|
||||
data=payload.data,
|
||||
target_scope=payload.target_scope,
|
||||
device_ids=payload.device_ids,
|
||||
)
|
||||
return DispatchResult(command_id=command_id, delivered_to=delivered, skipped=skipped)
|
||||
|
||||
|
||||
@app.get("/api/v4/update/manifest/{platform}")
|
||||
async def get_update_manifest(platform: str) -> UpdateManifest:
|
||||
return store.get_update_manifest(platform)
|
||||
|
||||
|
||||
@app.put("/api/v4/update/manifest/{platform}", dependencies=[Depends(require_token)])
|
||||
async def put_update_manifest(platform: str, manifest: UpdateManifest) -> dict[str, Any]:
|
||||
store.set_update_manifest(platform, manifest)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.websocket("/ws/device")
|
||||
async def device_socket(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
device_id = ""
|
||||
try:
|
||||
first = await websocket.receive_json()
|
||||
if first.get("type") != "hello":
|
||||
await websocket.send_json({"type": "error", "error": "first frame must be hello"})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
token = first.get("token", "")
|
||||
if Config.api_token and token != Config.api_token:
|
||||
await websocket.send_json({"type": "error", "error": "invalid token"})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
hello = DeviceHello.model_validate(first.get("payload", {}))
|
||||
device_id = hello.device_id
|
||||
await manager.connect(hello, websocket)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "welcome",
|
||||
"server_time": utc_now_iso(),
|
||||
"settings": store.get_settings(),
|
||||
}
|
||||
)
|
||||
|
||||
while True:
|
||||
msg = await websocket.receive_json()
|
||||
mtype = msg.get("type")
|
||||
if mtype == "heartbeat":
|
||||
await manager.touch(device_id)
|
||||
await websocket.send_json({"type": "heartbeat_ack", "time": utc_now_iso()})
|
||||
elif mtype == "ack":
|
||||
await manager.add_event(
|
||||
{
|
||||
"event": "command_ack",
|
||||
"device_id": device_id,
|
||||
"command_id": msg.get("command_id", ""),
|
||||
"status": msg.get("status", "ok"),
|
||||
"detail": msg.get("detail", ""),
|
||||
}
|
||||
)
|
||||
elif mtype == "event":
|
||||
await manager.add_event(
|
||||
{
|
||||
"event": "client_event",
|
||||
"device_id": device_id,
|
||||
"name": msg.get("name", ""),
|
||||
"data": msg.get("data", {}),
|
||||
}
|
||||
)
|
||||
else:
|
||||
await manager.add_event(
|
||||
{
|
||||
"event": "unknown_message",
|
||||
"device_id": device_id,
|
||||
"payload": msg,
|
||||
}
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc:
|
||||
await manager.add_event(
|
||||
{
|
||||
"event": "socket_error",
|
||||
"device_id": device_id,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
if device_id:
|
||||
await manager.disconnect(device_id)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Legacy compatibility routes
|
||||
# ----------------------------
|
||||
|
||||
|
||||
@app.get("/api/usecases")
|
||||
async def legacy_get_usecases() -> dict[str, Any]:
|
||||
return {
|
||||
"usecases": store.get_usecases(),
|
||||
"active": _active_usecase_id(),
|
||||
"blocks": ALL_BLOCKS,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/usecases")
|
||||
async def legacy_create_usecase(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
name = str(payload.get("name", "")).strip()
|
||||
if not name:
|
||||
return {"ok": False, "error": "name required"}
|
||||
uid = re.sub(r"[^a-z0-9_]", "_", name.lower()) + f"_{random.randint(100,999)}"
|
||||
uc = {
|
||||
"id": uid,
|
||||
"name": name,
|
||||
"color": payload.get("color", "#4f8ef7"),
|
||||
"group": str(payload.get("group", "")).strip(),
|
||||
"blocks": payload.get("blocks", ["notifications"]),
|
||||
"min_interval": int(payload.get("min_interval", 10)),
|
||||
"max_interval": int(payload.get("max_interval", 30)),
|
||||
"notifications": payload.get("notifications", []),
|
||||
"dashboard_layout": payload.get("dashboard_layout", {}),
|
||||
}
|
||||
usecases = store.get_usecases()
|
||||
usecases.append(uc)
|
||||
store.set_usecases(usecases)
|
||||
_ensure_usecase_dirs(uid)
|
||||
await _append_log(f"Usecase created: {name}")
|
||||
return {"ok": True, "usecase": uc}
|
||||
|
||||
|
||||
@app.put("/api/usecases/{uid}")
|
||||
async def legacy_update_usecase(uid: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
usecases = store.get_usecases()
|
||||
found = None
|
||||
for uc in usecases:
|
||||
if uc.get("id") == uid:
|
||||
uc.update({k: v for k, v in payload.items() if k != "id"})
|
||||
found = uc
|
||||
break
|
||||
if not found:
|
||||
return {"ok": False, "error": "not found"}
|
||||
store.set_usecases(usecases)
|
||||
_ensure_usecase_dirs(uid)
|
||||
await _append_log(f"Usecase updated: {found.get('name', uid)}")
|
||||
return {"ok": True, "usecase": found}
|
||||
|
||||
|
||||
@app.delete("/api/usecases/{uid}")
|
||||
async def legacy_delete_usecase(uid: str) -> dict[str, Any]:
|
||||
usecases = store.get_usecases()
|
||||
after = [u for u in usecases if u.get("id") != uid]
|
||||
if len(after) == len(usecases):
|
||||
return {"ok": False, "error": "not found"}
|
||||
store.set_usecases(after)
|
||||
if _active_usecase_id() == uid:
|
||||
_set_active_usecase(after[0]["id"] if after else "")
|
||||
await _append_log(f"Usecase deleted: {uid}", "warn")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/usecases/{uid}/activate")
|
||||
async def legacy_activate_usecase(uid: str) -> dict[str, Any]:
|
||||
usecases = store.get_usecases()
|
||||
exists = any(u.get("id") == uid for u in usecases)
|
||||
if not exists:
|
||||
return {"ok": False, "error": "not found"}
|
||||
_set_active_usecase(uid)
|
||||
uc = next((u for u in usecases if u.get("id") == uid), {})
|
||||
await _append_log(f"Switched to usecase '{uc.get('name', uid)}'")
|
||||
return {"ok": True, "active": uid}
|
||||
|
||||
|
||||
@app.post("/api/usecases/{uid}/open_folder")
|
||||
async def legacy_open_usecase_folder(uid: str) -> dict[str, Any]:
|
||||
_ensure_usecase_dirs(uid)
|
||||
d = Config.data_dir / "usecases" / uid
|
||||
return {"ok": True, "path": str(d)}
|
||||
|
||||
|
||||
@app.get("/api/state")
|
||||
async def legacy_state() -> dict[str, Any]:
|
||||
settings = store.get_settings()
|
||||
uc = _active_usecase()
|
||||
logs_raw = await manager.get_logs()
|
||||
logs = [_event_to_legacy_log(e) for e in logs_raw[:100]]
|
||||
connected_devices = [d.model_dump() for d in await manager.list_devices()]
|
||||
entries: list[dict[str, Any]] = []
|
||||
if uc:
|
||||
entries = [_parse_entry(x) for x in uc.get("notifications", []) if str(x).strip()]
|
||||
min_interval = int((uc or {}).get("min_interval", 10))
|
||||
next_fire_at = int(time.time()) + (min_interval * 60)
|
||||
|
||||
cutoff = time.time() - 60
|
||||
stale = [k for k, v in _pwa_clients.items() if v.get("last_seen", 0) < cutoff]
|
||||
for k in stale:
|
||||
_pwa_clients.pop(k, None)
|
||||
|
||||
return {
|
||||
"app_name": (uc or {}).get("name") or settings.get("app_name", "NotifyPulse"),
|
||||
"settings_app_name": settings.get("app_name", "NotifyPulse"),
|
||||
"version": app.version,
|
||||
"paused": bool(settings.get("paused", False)),
|
||||
"next_fire_at": next_fire_at,
|
||||
"entries": entries,
|
||||
"log": logs,
|
||||
"pwa_clients": len(_pwa_clients),
|
||||
"connected_devices": connected_devices,
|
||||
"settings": settings,
|
||||
"active_usecase": _active_usecase_id(),
|
||||
"usecase": uc,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/pause")
|
||||
async def legacy_pause() -> dict[str, Any]:
|
||||
settings = store.get_settings()
|
||||
paused = not bool(settings.get("paused", False))
|
||||
store.update_settings({"paused": paused})
|
||||
return {"paused": paused}
|
||||
|
||||
|
||||
@app.post("/api/fire_now")
|
||||
async def legacy_fire_now(request: Request) -> dict[str, Any]:
|
||||
uc = _active_usecase()
|
||||
if not uc:
|
||||
return {"ok": False, "error": "no active usecase"}
|
||||
entries = [str(x) for x in uc.get("notifications", []) if str(x).strip()]
|
||||
if not entries:
|
||||
return {"ok": False, "error": "No entries"}
|
||||
entry = random.choice(entries).split("|", 1)[0].strip()
|
||||
tl = entry.lower()
|
||||
|
||||
if tl == "change.wallpaper":
|
||||
img = _pick_usecase_image("wallpapers")
|
||||
if not img:
|
||||
await _append_log("Fire now: wallpaper missing", "warn")
|
||||
return {"ok": False, "error": "No wallpaper images found"}
|
||||
uid = str(uc.get("id", ""))
|
||||
url = _build_asset_url(request, uid, "wallpapers", img.name)
|
||||
res = await dispatch(
|
||||
DispatchRequest(
|
||||
action="wallpaper_set",
|
||||
data={"url": url},
|
||||
target_scope="windows",
|
||||
)
|
||||
)
|
||||
await _append_log(f"Fire now: wallpaper -> {img.name}")
|
||||
return {
|
||||
"ok": True,
|
||||
"fired": entry,
|
||||
"action": "wallpaper_set",
|
||||
"asset_url": url,
|
||||
"command_id": res.command_id,
|
||||
}
|
||||
|
||||
if tl.startswith("show.overlay"):
|
||||
sec = 6
|
||||
m = re.match(r"^show\.overlay\.(\d+)$", tl)
|
||||
if m:
|
||||
sec = max(1, min(60, int(m.group(1))))
|
||||
img = _pick_usecase_image("overlay")
|
||||
if not img:
|
||||
await _append_log("Fire now: overlay missing", "warn")
|
||||
return {"ok": False, "error": "No overlay images found"}
|
||||
_pwa_overlay.update(
|
||||
{
|
||||
"active": True,
|
||||
"image": _img_to_datauri(img),
|
||||
"until_ms": int(time.time() * 1000) + (sec * 1000),
|
||||
}
|
||||
)
|
||||
overlay_url = _build_asset_url(request, str(uc.get("id", "")), "overlay", img.name)
|
||||
settings = store.get_settings()
|
||||
opacity = float(settings.get("overlay_opacity", 0.4))
|
||||
stretch = bool(settings.get("overlay_stretch", False))
|
||||
res = await dispatch(
|
||||
DispatchRequest(
|
||||
action="overlay_show",
|
||||
data={
|
||||
"url": overlay_url,
|
||||
"duration_ms": sec * 1000,
|
||||
"opacity": opacity,
|
||||
"stretch": stretch,
|
||||
},
|
||||
target_scope="windows",
|
||||
)
|
||||
)
|
||||
await _append_log(f"Fire now: overlay -> {img.name} ({sec}s)")
|
||||
return {
|
||||
"ok": True,
|
||||
"fired": entry,
|
||||
"action": "overlay_show",
|
||||
"duration_sec": sec,
|
||||
"command_id": res.command_id,
|
||||
}
|
||||
|
||||
if tl == "change.wallpaper.mobile":
|
||||
payload = _pick_mobile_wallpaper_payload()
|
||||
if not payload:
|
||||
await _append_log("Fire now: mobile wallpaper missing", "warn")
|
||||
return {"ok": False, "error": "No mobile images found"}
|
||||
queued = _queue_mobile_payload(payload)
|
||||
await _append_log(f"Fire now: mobile wallpaper queued -> {queued} client(s)")
|
||||
return {"ok": True, "fired": entry}
|
||||
|
||||
cmd = DispatchRequest(
|
||||
action="notify",
|
||||
data={"title": uc.get("name", "NotifyPulse"), "message": entry},
|
||||
target_scope="windows",
|
||||
)
|
||||
res = await dispatch(cmd)
|
||||
await _append_log(f"Fire now notification -> {entry}")
|
||||
return {"ok": True, "fired": entry, "command_id": res.command_id}
|
||||
|
||||
|
||||
@app.get("/api/settings")
|
||||
async def legacy_get_settings() -> dict[str, Any]:
|
||||
return store.get_settings()
|
||||
|
||||
|
||||
@app.post("/api/settings")
|
||||
async def legacy_post_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
store.update_settings(payload)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/log")
|
||||
async def legacy_log(limit: int = 100) -> list[dict[str, Any]]:
|
||||
logs = await manager.get_logs()
|
||||
adapted = [_event_to_legacy_log(e) for e in logs]
|
||||
return adapted[: min(limit, 200)]
|
||||
|
||||
|
||||
@app.post("/api/test_notification")
|
||||
async def legacy_test_notification(payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
p = payload or {}
|
||||
cmd = DispatchRequest(
|
||||
action="notify",
|
||||
data={"title": "NotifyPulse", "message": p.get("message", "Test from Web UI")},
|
||||
target_scope="windows",
|
||||
)
|
||||
res = await dispatch(cmd)
|
||||
if not res.delivered_to:
|
||||
await _append_log("Desktop test notification: no Windows clients connected", "warn")
|
||||
return {"ok": False, "error": "No Windows clients connected"}
|
||||
await _append_log("Desktop test notification sent")
|
||||
return {"ok": True, "command_id": res.command_id}
|
||||
|
||||
|
||||
@app.post("/api/test_wallpaper")
|
||||
async def legacy_test_wallpaper(request: Request) -> dict[str, Any]:
|
||||
uc = _active_usecase()
|
||||
if not uc:
|
||||
return {"ok": False, "error": "No active usecase"}
|
||||
img = _pick_usecase_image("wallpapers")
|
||||
if not img:
|
||||
await _append_log("Desktop test wallpaper: no wallpaper images found", "warn")
|
||||
return {"ok": False, "error": "No images in wallpapers folder"}
|
||||
uid = str(uc.get("id", ""))
|
||||
url = _build_asset_url(request, uid, "wallpapers", img.name)
|
||||
res = await dispatch(
|
||||
DispatchRequest(
|
||||
action="wallpaper_set",
|
||||
data={"url": url},
|
||||
target_scope="windows",
|
||||
)
|
||||
)
|
||||
if not res.delivered_to:
|
||||
await _append_log("Desktop test wallpaper: no Windows clients connected", "warn")
|
||||
return {"ok": False, "error": "No Windows clients connected"}
|
||||
await _append_log(f"Desktop test wallpaper -> {img.name}")
|
||||
return {"ok": True, "command_id": res.command_id}
|
||||
|
||||
|
||||
@app.post("/api/test_overlay")
|
||||
async def legacy_test_overlay(request: Request) -> dict[str, Any]:
|
||||
uc = _active_usecase()
|
||||
if not uc:
|
||||
return {"ok": False, "error": "No active usecase"}
|
||||
img = _pick_usecase_image("overlay")
|
||||
if not img:
|
||||
await _append_log("Desktop test overlay: no overlay images found", "warn")
|
||||
return {"ok": False, "error": "No images in overlay folder"}
|
||||
uid = str(uc.get("id", ""))
|
||||
url = _build_asset_url(request, uid, "overlay", img.name)
|
||||
settings = store.get_settings()
|
||||
dur_ms = int(float(settings.get("overlay_duration", 6)) * 1000)
|
||||
opacity = float(settings.get("overlay_opacity", 0.4))
|
||||
stretch = bool(settings.get("overlay_stretch", False))
|
||||
_pwa_overlay.update(
|
||||
{
|
||||
"active": True,
|
||||
"image": _img_to_datauri(img),
|
||||
"until_ms": int(time.time() * 1000) + dur_ms,
|
||||
}
|
||||
)
|
||||
res = await dispatch(
|
||||
DispatchRequest(
|
||||
action="overlay_show",
|
||||
data={
|
||||
"url": url,
|
||||
"duration_ms": dur_ms,
|
||||
"opacity": opacity,
|
||||
"stretch": stretch,
|
||||
},
|
||||
target_scope="windows",
|
||||
)
|
||||
)
|
||||
if not res.delivered_to:
|
||||
await _append_log("Desktop test overlay: no Windows clients connected", "warn")
|
||||
return {"ok": False, "error": "No Windows clients connected"}
|
||||
await _append_log(f"Desktop test overlay -> {img.name}")
|
||||
return {"ok": True, "command_id": res.command_id}
|
||||
|
||||
|
||||
@app.post("/api/test_mobile_wallpaper")
|
||||
async def legacy_test_mobile() -> dict[str, Any]:
|
||||
payload = _pick_mobile_wallpaper_payload()
|
||||
if not payload:
|
||||
await _append_log("Mobile test wallpaper: no mobile images found", "warn")
|
||||
return {"ok": False, "error": "no mobile wallpaper found for active usecase"}
|
||||
queued = _queue_mobile_payload(payload)
|
||||
await _append_log(f"Mobile test wallpaper queued -> {queued} client(s)")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/pwa/ping")
|
||||
async def legacy_pwa_ping(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
cid = str(payload.get("client_id", "")).strip()
|
||||
if not cid:
|
||||
return {"error": "no client_id"}
|
||||
_pwa_clients[cid] = {"last_seen": time.time()}
|
||||
return {"ok": True, "clients": len(_pwa_clients)}
|
||||
|
||||
|
||||
@app.get("/api/pwa/wallpaper")
|
||||
async def legacy_pwa_wallpaper(client_id: str = "") -> dict[str, Any]:
|
||||
cid = client_id.strip()
|
||||
if not cid:
|
||||
return {"pending": False}
|
||||
cmd = _pending_wallpapers.pop(cid, None) or _pending_wallpapers.pop("__broadcast__", None)
|
||||
if not cmd:
|
||||
return {"pending": False}
|
||||
return {"pending": True, **cmd}
|
||||
|
||||
|
||||
@app.get("/api/pwa/app_name")
|
||||
async def legacy_pwa_app_name() -> dict[str, Any]:
|
||||
settings = store.get_settings()
|
||||
return {
|
||||
"app_name": (_active_usecase() or {}).get("name") or settings.get("app_name", "NotifyPulse"),
|
||||
"settings_app_name": settings.get("app_name", "NotifyPulse"),
|
||||
"active": _active_usecase_id(),
|
||||
"usecases": [
|
||||
{
|
||||
"id": u.get("id", ""),
|
||||
"name": u.get("name", ""),
|
||||
"color": u.get("color", "#4f8ef7"),
|
||||
"group": u.get("group", ""),
|
||||
"blocks": u.get("blocks", []),
|
||||
"min_interval": u.get("min_interval", 10),
|
||||
"max_interval": u.get("max_interval", 30),
|
||||
}
|
||||
for u in store.get_usecases()
|
||||
],
|
||||
"version": app.version,
|
||||
"blocks": ALL_BLOCKS,
|
||||
"pwa_bg_blur": settings.get("pwa_bg_blur", 18),
|
||||
"pwa_bg_opacity": settings.get("pwa_bg_opacity", 0.72),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/pwa/splash_image")
|
||||
async def legacy_pwa_splash() -> dict[str, Any]:
|
||||
splash_dir = Config.data_dir / "splash"
|
||||
candidates: list[Path] = []
|
||||
if splash_dir.exists():
|
||||
candidates = [
|
||||
p
|
||||
for p in splash_dir.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}
|
||||
]
|
||||
if not candidates:
|
||||
for name in ("main.png", "main.jpg", "main.jpeg", "main.webp"):
|
||||
p = Config.data_dir / name
|
||||
if p.exists():
|
||||
candidates = [p]
|
||||
break
|
||||
if not candidates:
|
||||
return {"image": "", "has_custom": False}
|
||||
chosen = random.choice(candidates)
|
||||
return {"image": _img_to_datauri(chosen) or "", "has_custom": True}
|
||||
|
||||
|
||||
@app.get("/api/pwa/overlay")
|
||||
async def legacy_pwa_overlay() -> dict[str, Any]:
|
||||
if not _pwa_overlay.get("active"):
|
||||
return {"active": False, "image": None}
|
||||
remaining = int(_pwa_overlay.get("until_ms", 0) - (time.time() * 1000))
|
||||
if remaining <= 0:
|
||||
_pwa_overlay.update({"active": False, "image": None, "until_ms": 0})
|
||||
return {"active": False, "image": None}
|
||||
return {"active": True, "image": _pwa_overlay.get("image"), "remaining_ms": remaining}
|
||||
|
||||
|
||||
@app.post("/api/pwa/trigger_wallpaper")
|
||||
async def legacy_pwa_trigger_wallpaper() -> dict[str, Any]:
|
||||
payload = _pick_mobile_wallpaper_payload()
|
||||
if not payload:
|
||||
await _append_log("PWA wallpaper request: no mobile images found", "warn")
|
||||
return {"ok": False, "error": "no mobile wallpaper found for active usecase"}
|
||||
queued = _queue_mobile_payload(payload)
|
||||
await _append_log(f"PWA wallpaper queued -> {queued} client(s)")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/pwa/activate_usecase")
|
||||
async def legacy_pwa_activate_usecase(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
uid = str(payload.get("usecase_id", "")).strip()
|
||||
usecases = store.get_usecases()
|
||||
uc = next((u for u in usecases if u.get("id") == uid), None)
|
||||
if not uc:
|
||||
return {"ok": False, "error": "not found"}
|
||||
_set_active_usecase(uid)
|
||||
return {"ok": True, "name": uc.get("name", "")}
|
||||
|
||||
|
||||
@app.get("/api/v4/assets/usecases/{uid}/{bucket}/{filename}")
|
||||
async def usecase_asset(uid: str, bucket: str, filename: str):
|
||||
if bucket not in {"wallpapers", "overlay", "mobile"}:
|
||||
raise HTTPException(status_code=404, detail="invalid asset bucket")
|
||||
if "/" in filename or "\\" in filename:
|
||||
raise HTTPException(status_code=400, detail="invalid filename")
|
||||
path = (Config.data_dir / "usecases" / uid / bucket / filename).resolve()
|
||||
root = (Config.data_dir / "usecases" / uid / bucket).resolve()
|
||||
if root not in path.parents and path != root:
|
||||
raise HTTPException(status_code=400, detail="invalid asset path")
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="asset not found")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
def _safe_file(path: Path) -> FileResponse | JSONResponse:
|
||||
if path.exists() and path.is_file():
|
||||
return FileResponse(path)
|
||||
return JSONResponse(
|
||||
{"error": "file not found", "path": str(path)},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def web_ui_index():
|
||||
return _safe_file(Config.ui_dir / "index.html")
|
||||
|
||||
|
||||
@app.get("/pwa")
|
||||
@app.get("/pwa/")
|
||||
async def pwa_index():
|
||||
return _safe_file(Config.pwa_dir / "index.html")
|
||||
|
||||
|
||||
@app.get("/pwa/{filename:path}")
|
||||
async def pwa_assets(filename: str):
|
||||
return _safe_file(Config.pwa_dir / filename)
|
||||
Reference in New Issue
Block a user