"""Social-media / QR display on the LED mask. Renders a QR code (for a preset Instagram account) or an uploaded image onto the mask's 46x58 display and holds it via the FaceController's reserved scratch slot until the animated face is resumed. The shared helper :func:`show_social_on_mask` is also called from the Gemini ``[[SHOW:account]]`` relay wired in ``main.py``. Routes (under /api/mask): POST /social/{account} -> show a preset Instagram QR POST /qr -> upload an image (QR or any picture) + show it POST /face/resume -> stop showing the scratch image, return to the face GET /social -> list the preset accounts """ from __future__ import annotations import asyncio import io import logging import os import sys from pathlib import Path import re from fastapi import APIRouter, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse log = logging.getLogger("sanad.mask_social") router = APIRouter() # prefix "/api/mask" supplied by dashboard/app.py _REST_ROUTES # Preset Instagram accounts the mask can show as a QR. The mask is a low-res # 46x58 panel, so a full-URL QR is dense; the black margin acts as the quiet # zone and we scale modules crisply (NEAREST) to give it the best chance. SOCIAL = { "bu_sunaidah": {"handle": "@bu.sunaidah", "url": "https://instagram.com/bu.sunaidah", "short": "da.gd/VMkH8J"}, # -> instagram.com/bu.sunaidah (v1 QR) "yslootahtech": {"handle": "@yslootahtech", "url": "https://instagram.com/yslootahtech", "short": "da.gd/Qr8RO"}, # -> instagram.com/yslootahtech (v1 QR) } def _ensure_mask_path() -> None: """Make the flat Mask lib (colorface) importable from this route — using the SAME dir the FaceController resolved (the Mask lib lives outside the repo).""" d = os.environ.get("SANAD_MASK_DIR") if not d: try: from Project.Sanad.main import mask_face as _mf # type: ignore d = getattr(_mf, "mask_dir", None) except Exception: d = None if not d: d = str(Path(__file__).resolve().parents[2] / "Mask") if d and d not in sys.path: sys.path.insert(0, d) def _get_face(): from Project.Sanad.main import mask_face # type: ignore if mask_face is None: raise HTTPException(status_code=503, detail="mask face unavailable") return mask_face _EYE_BAND = 16 # top rows reserved for the cyan eyes; the code sits below them def _compose_under_eyes(inner) -> bytes: """Draw two cyan eyes across the top and place ``inner`` (a QR / image) in the area BELOW them, then encode for the mask. Keeps the panel looking like a face with a code under the eyes instead of a full-screen QR.""" _ensure_mask_path() import colorface as cf from PIL import Image, ImageDraw W, H = cf.DISPLAY_W, cf.DISPLAY_H inner = inner.convert("RGB") iw, ih = inner.size # keep the code a small badge under the eyes (~70% of the space below them) target = max(20, int(min(W, H - _EYE_BAND - 1) * 0.72)) if iw <= target and ih <= target: s = max(1, min(target // iw, target // ih)) # crisp integer up-scale (QR) nw, nh = iw * s, ih * s else: s = min(target / iw, target / ih) # scale big images down nw, nh = max(1, int(iw * s)), max(1, int(ih * s)) inner = inner.resize((nw, nh), Image.NEAREST) canvas = Image.new("RGB", (W, H), (0, 0, 0)) g = ImageDraw.Draw(canvas) eye = cf.DEFAULT_EYE for cx in (W // 2 - 10, W // 2 + 10): # two eyes at the top g.ellipse([cx - 5, 3, cx + 5, 13], fill=(255, 255, 255)) g.ellipse([cx - 3, 5, cx + 3, 11], fill=eye) g.ellipse([cx - 1, 7, cx + 1, 10], fill=(0, 0, 0)) x = (W - nw) // 2 y = _EYE_BAND + (H - _EYE_BAND - nh) // 2 canvas.paste(inner, (max(0, x), max(_EYE_BAND, y))) return cf.encode(canvas) def _qr_bytes(url: str) -> bytes: """Render a QR for ``url`` FULL-SCREEN with the largest crisp (integer) module size the 46-wide panel allows — the only way it has any chance of scanning. Only a ~version-1 QR (<=17 chars) reaches ~2 px/module; longer data is denser and won't scan. Returns (bytes, qr_version).""" _ensure_mask_path() import qrcode from PIL import Image import colorface as cf W, H = cf.DISPLAY_W, cf.DISPLAY_H qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) qr.add_data(url) qr.make(fit=True) q = qr.make_image(fill_color=(255, 255, 255), back_color=(0, 0, 0)).convert("RGB") scale = max(1, min(W, H) // max(1, q.width)) # largest integer that fits if scale > 1: q = q.resize((q.width * scale, q.width * scale), Image.NEAREST) canvas = Image.new("RGB", (W, H), (0, 0, 0)) canvas.paste(q, ((W - q.width) // 2, (H - q.height) // 2)) return cf.encode(canvas) def _image_bytes(img) -> bytes: """Show an uploaded QR/image FULL-SCREEN, crisp (NEAREST) — best effort.""" _ensure_mask_path() import colorface as cf from PIL import Image W, H = cf.DISPLAY_W, cf.DISPLAY_H s = min(W, H) img = img.convert("RGB").resize((s, s), Image.NEAREST) canvas = Image.new("RGB", (W, H), (0, 0, 0)) canvas.paste(img, ((W - s) // 2, (H - s) // 2)) return cf.encode(canvas) def show_social_on_mask(account: str) -> dict: """Show the account's **scannable** QR on the mask — a version-1 QR made from a short (da.gd) link that redirects to the Instagram profile. Shared by the dashboard button and the Gemini ``show_social`` tool. Raises for an unknown account; propagates FaceController errors (e.g. not connected).""" acc = SOCIAL.get(str(account).strip().lower()) if not acc: raise HTTPException(status_code=404, detail="unknown account") data = _qr_bytes(acc.get("short") or acc["url"]) # v1 short link -> scannable mf = _get_face() res = mf.show_scratch_image(data) log.info("showing scannable social QR on mask: %s (%s)", acc["handle"], acc.get("short")) return {"ok": True, "handle": acc["handle"], "scannable": True, **(res or {})} @router.get("/social") async def list_social(): return {"accounts": [{"id": k, "handle": v["handle"]} for k, v in SOCIAL.items()]} def _friendly(exc: Exception) -> HTTPException: """Map FaceController errors to clean HTTP responses (esp. the common 'mask not connected' — usually the mask is off / far / held by the phone app).""" if isinstance(exc, HTTPException): return exc msg = str(exc) if "not connected" in msg or "not started" in msg or "MASK" in msg: return HTTPException(status_code=503, detail=( "Mask not connected — power it on, bring it close to the robot, and " "free it from the phone app.")) log.exception("mask scratch op failed") return HTTPException(status_code=500, detail="%s: %s" % (type(exc).__name__, msg)) @router.post("/social/{account}") async def show_social(account: str): try: return await asyncio.to_thread(show_social_on_mask, account) except Exception as exc: raise _friendly(exc) @router.post("/qr") async def upload_qr(file: UploadFile = File(...)): """Upload an image (a QR you generated, or any picture) and show it on the mask.""" raw = await file.read() if not raw: raise HTTPException(status_code=400, detail="empty upload") from PIL import Image try: img = Image.open(io.BytesIO(raw)) img.load() except Exception: raise HTTPException(status_code=400, detail="not a valid image") try: data = await asyncio.to_thread(_image_bytes, img) mf = _get_face() return await asyncio.to_thread(mf.show_scratch_image, data) except Exception as exc: raise _friendly(exc) @router.post("/face/resume") async def resume_face(): """Stop showing the scratch image and resume the animated face.""" mf = _get_face() return await asyncio.to_thread(mf.set_expression, None) @router.post("/face/mouth") async def face_mouth(hidden: bool = Query(...)): """Show (hidden=false) or hide (hidden=true) the mouth on the animated face.""" mf = _get_face() return await asyncio.to_thread(mf.set_mouth_hidden, hidden) @router.post("/link") async def face_link(on: bool = Query(...)): """Link (on=true) / unlink (on=false) Gemini <-> the mask. ON connects the mask + lets Gemini drive its emotions/social. OFF tears the link down (no BLE churn) and Gemini stops touching the mask. Default state is OFF. Runs in a thread — a link-on may briefly block while it makes its first connect attempt.""" mf = _get_face() return await asyncio.to_thread(mf.set_gemini_linked, on) # ── saved QR library ──────────────────────────────────────────────── # Upload QR/images, save them by name, list/show/delete them. Stored as PNGs # under data/qr_codes so they persist across restarts. _QR_DIR = None def _qr_dir() -> Path: global _QR_DIR if _QR_DIR is None: try: from Project.Sanad.config import BASE_DIR base = Path(BASE_DIR) except Exception: base = Path(__file__).resolve().parents[2] _QR_DIR = base / "data" / "qr_codes" _QR_DIR.mkdir(parents=True, exist_ok=True) return _QR_DIR def _safe_name(name: str) -> str: n = re.sub(r"[^A-Za-z0-9_.-]", "_", (name or "").strip())[:40].strip("._") return n or "qr" @router.post("/qr/save") async def qr_save(name: str = Query(...), file: UploadFile = File(...)): """Save an uploaded QR/image into the library under ``name``.""" raw = await file.read() if not raw: raise HTTPException(status_code=400, detail="empty upload") from PIL import Image try: img = Image.open(io.BytesIO(raw)) img.load() except Exception: raise HTTPException(status_code=400, detail="not a valid image") sn = _safe_name(name) await asyncio.to_thread(img.convert("RGB").save, str(_qr_dir() / (sn + ".png"))) return {"ok": True, "name": sn} @router.post("/qr/save_link") async def qr_save_link(name: str = Query(...), url: str = Query(...)): """Generate a QR from ``url`` and save it to the library. Returns the QR version + whether it's short enough to actually scan on the mask (version 1).""" u = (url or "").strip() if not u: raise HTTPException(status_code=400, detail="empty url") _ensure_mask_path() import qrcode qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=2) qr.add_data(u) qr.make(fit=True) img = qr.make_image(fill_color=(255, 255, 255), back_color=(0, 0, 0)).convert("RGB") sn = _safe_name(name or u) await asyncio.to_thread(img.save, str(_qr_dir() / (sn + ".png"))) return {"ok": True, "name": sn, "version": qr.version, "scannable_on_mask": qr.version <= 1, "note": ("scannable" if qr.version <= 1 else "too dense to scan on the mask — use a shorter link")} @router.get("/qr/library") async def qr_library(): """List the saved QR names.""" return {"qr": sorted(p.stem for p in _qr_dir().glob("*.png"))} @router.get("/qr/thumb/{name}") async def qr_thumb(name: str): """Serve a saved QR image (for the dashboard thumbnail).""" p = _qr_dir() / (_safe_name(name) + ".png") if not p.exists(): raise HTTPException(status_code=404, detail="not found") return FileResponse(str(p), media_type="image/png") @router.post("/qr/show/{name}") async def qr_show(name: str): """Show a saved QR (under the eyes) on the mask.""" p = _qr_dir() / (_safe_name(name) + ".png") if not p.exists(): raise HTTPException(status_code=404, detail="not found") from PIL import Image try: img = Image.open(p) data = await asyncio.to_thread(_image_bytes, img) mf = _get_face() return await asyncio.to_thread(mf.show_scratch_image, data) except Exception as exc: raise _friendly(exc) @router.delete("/qr/{name}") async def qr_delete(name: str): """Delete a saved QR from the library.""" p = _qr_dir() / (_safe_name(name) + ".png") if p.exists(): p.unlink() return {"ok": True, "deleted": _safe_name(name)} # ── saved TEXT library ────────────────────────────────────────────── # Save words/phrases and scroll any of them across the mask on demand. _TEXT_DIR = None def _text_dir() -> Path: global _TEXT_DIR if _TEXT_DIR is None: try: from Project.Sanad.config import BASE_DIR base = Path(BASE_DIR) except Exception: base = Path(__file__).resolve().parents[2] _TEXT_DIR = base / "data" / "mask_texts" _TEXT_DIR.mkdir(parents=True, exist_ok=True) return _TEXT_DIR @router.post("/texts/save") async def text_save(text: str = Query(...), name: str = Query("")): """Save a word/phrase to the text library (name defaults to the text).""" t = (text or "").strip()[:200] if not t: raise HTTPException(status_code=400, detail="empty text") nm = _safe_name(name or t) await asyncio.to_thread((_text_dir() / (nm + ".txt")).write_text, t) return {"ok": True, "name": nm, "text": t} @router.get("/texts/library") async def text_library(): """List the saved texts.""" out = [] for p in sorted(_text_dir().glob("*.txt")): try: out.append({"name": p.stem, "text": p.read_text()[:80]}) except Exception: pass return {"texts": out} @router.post("/texts/show/{name}") async def text_show(name: str): """Scroll a saved text across the mask.""" p = _text_dir() / (_safe_name(name) + ".txt") if not p.exists(): raise HTTPException(status_code=404, detail="not found") txt = p.read_text() mf = _get_face() try: return await asyncio.to_thread(mf.set_text, txt, (255, 255, 255), None, None, 38) except Exception as exc: raise _friendly(exc) @router.delete("/texts/{name}") async def text_delete(name: str): """Delete a saved text.""" p = _text_dir() / (_safe_name(name) + ".txt") if p.exists(): p.unlink() return {"ok": True, "deleted": _safe_name(name)}