orca_cloud.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. """
  2. Orca Cloud API Routes
  3. Device-pairing (RFC 8628) connect/disconnect + profile sync endpoints for the
  4. Orca Cloud external-app surface.
  5. Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
  6. POST /orca-cloud/device/start
  7. Request a device code, persist it server-side (TTL 10 min), return the
  8. user_code + verification URIs + poll interval.
  9. POST /orca-cloud/device/poll
  10. One poll of the token endpoint. Returns an in-progress status while the
  11. user approves; on approval, persists the token pair and reports
  12. connected. The frontend calls this every ``interval`` seconds.
  13. GET /orca-cloud/status
  14. Connected/disconnected + user_id.
  15. POST /orca-cloud/logout
  16. Clear stored tokens (Bambuddy then has no token to use; the user can
  17. also disconnect from Orca Cloud's own settings to revoke server-side).
  18. GET /orca-cloud/profiles
  19. List of the user's Orca Cloud profiles, grouped by type. JIT-refreshes
  20. the access token if it's within the refresh leeway of expiry.
  21. GET /orca-cloud/profiles/{id}
  22. Single profile's full content.
  23. Storage shape mirrors the Bambu Cloud surface: per-user columns on ``users``
  24. when auth is enabled, fallback to global ``settings`` keys when auth is
  25. disabled. The transient pending device-code state (device_code, interval,
  26. started_at) reuses the ``orca_cloud_pending_*`` columns — same dual-mode
  27. pattern; no schema change from the previous PKCE flow.
  28. """
  29. from __future__ import annotations
  30. import logging
  31. from datetime import datetime, timezone
  32. from fastapi import APIRouter, Depends, HTTPException, Request
  33. from sqlalchemy import select, update
  34. from sqlalchemy.ext.asyncio import AsyncSession
  35. from backend.app.api.routes.cloud import _cloud_api_key_gate, cloud_caller
  36. from backend.app.core.database import get_db
  37. from backend.app.core.permissions import Permission
  38. from backend.app.models.settings import Settings
  39. from backend.app.models.user import User
  40. from backend.app.schemas.orca_cloud import (
  41. OrcaAuthStatusResponse,
  42. OrcaDevicePollResponse,
  43. OrcaDeviceStartResponse,
  44. OrcaProfileDetail,
  45. OrcaProfileListResponse,
  46. OrcaProfileMeta,
  47. )
  48. from backend.app.services.orca_cloud import (
  49. DEVICE_CODE_TTL,
  50. DevicePoll,
  51. OrcaCloudAuthError,
  52. OrcaCloudError,
  53. OrcaCloudService,
  54. )
  55. logger = logging.getLogger(__name__)
  56. # Router-level dependency: enforce the same API-key cloud-access fence as the
  57. # Bambu Cloud router (rejects ownerless legacy keys, requires the
  58. # ``can_access_cloud`` scope, stashes the owner on ``request.state`` so
  59. # per-route deps can resolve it as the effective ``current_user``).
  60. # Without this gate the kiosk's API-keyed requests sail past with
  61. # ``current_user=None`` → ``_build_authenticated_service`` falls back to
  62. # the global Settings table → no Orca token → 401, no presets surfaced.
  63. # Bambu Cloud works in the same kiosk because its router has this gate.
  64. router = APIRouter(prefix="/orca-cloud", tags=["orca-cloud"], dependencies=[Depends(_cloud_api_key_gate)])
  65. # Orca ``content.type`` values map onto Bambu Cloud's preset type vocabulary.
  66. # Empirically (confirmed against a live account on 2026-06-04): Orca uses
  67. # ``"printer"`` / ``"print"`` / ``"filament"`` — NOT the BambuStudio
  68. # ``"machine"`` / ``"process"`` / ``"filament"`` triplet that lives elsewhere
  69. # in the OrcaSlicer source. The aliases keep us forward-compatible if Orca
  70. # ever flips back to the older naming.
  71. _ORCA_TYPE_TO_BAMBU = {
  72. "filament": "filament",
  73. "printer": "printer",
  74. "machine": "printer", # alias for the BambuStudio-style naming
  75. "print": "process",
  76. "process": "process", # alias for the BambuStudio-style naming
  77. }
  78. def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
  79. """Normalize one Orca profile (``{id, name, content, ...}``) into a
  80. ``SlicerSetting``-shaped row. Returns ``None`` if the content isn't a dict
  81. or the type isn't one we render."""
  82. content = orca_profile.get("content") or {}
  83. if not isinstance(content, dict):
  84. return None
  85. bambu_type = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
  86. if bambu_type is None:
  87. return None
  88. pid = orca_profile.get("id")
  89. if pid is None:
  90. return None
  91. updated = orca_profile.get("updated_time")
  92. return OrcaProfileMeta(
  93. setting_id=str(pid),
  94. name=str(orca_profile.get("name") or pid),
  95. type=bambu_type,
  96. version=_str_or_none(content.get("version")),
  97. # ``from`` distinguishes ``system`` (bundled) from ``User`` (custom),
  98. # same field the Bambu source-of-truth uses for that distinction.
  99. user_id=_str_or_none(content.get("user_id") or content.get("from")),
  100. updated_time=str(updated) if updated is not None else None,
  101. # Every profile that lives in the user's Orca Cloud account is by
  102. # definition user-authored; bundled defaults aren't synced.
  103. is_custom=True,
  104. )
  105. def _str_or_none(value: object) -> str | None:
  106. """Cast non-empty scalars to ``str``; pass ``None`` and empty values
  107. through unchanged. Used to keep the response shape consistent when
  108. Orca's source data has heterogenous typing for the same field."""
  109. if value is None:
  110. return None
  111. s = str(value)
  112. return s if s else None
  113. # Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
  114. # pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
  115. # settings table see a consistent prefix. The ``pending_*`` keys hold the
  116. # transient device-code state (device_code / interval / started_at).
  117. _SETTINGS_KEYS = {
  118. "token": "orca_cloud_token",
  119. "refresh_token": "orca_cloud_refresh_token",
  120. "expires_at": "orca_cloud_expires_at", # ISO 8601 UTC string
  121. "email": "orca_cloud_email",
  122. "user_id": "orca_cloud_user_id",
  123. "pending_device_code": "orca_cloud_pending_verifier", # reused column
  124. "pending_interval": "orca_cloud_pending_state", # reused column
  125. "pending_at": "orca_cloud_pending_at", # ISO 8601 UTC string
  126. }
  127. # ---------------------------------------------------------------------------
  128. # Storage helpers — bridge User-row vs Settings-table fallback transparently
  129. # ---------------------------------------------------------------------------
  130. def _iso(dt: datetime | None) -> str | None:
  131. """Serialize a datetime to ISO 8601 UTC. ``None`` passes through."""
  132. if dt is None:
  133. return None
  134. if dt.tzinfo is None:
  135. dt = dt.replace(tzinfo=timezone.utc)
  136. return dt.astimezone(timezone.utc).isoformat()
  137. def _as_utc(dt: datetime | None) -> datetime | None:
  138. """Attach ``tzinfo=UTC`` to a naive datetime that we know was stored as
  139. UTC. ``None`` passes through. Already-aware datetimes are converted to
  140. UTC to normalize."""
  141. if dt is None:
  142. return None
  143. if dt.tzinfo is None:
  144. return dt.replace(tzinfo=timezone.utc)
  145. return dt.astimezone(timezone.utc)
  146. def _parse_iso(value: str | None) -> datetime | None:
  147. """Parse an ISO 8601 string back to a UTC datetime. ``None`` passes through."""
  148. if not value:
  149. return None
  150. try:
  151. dt = datetime.fromisoformat(value)
  152. except (TypeError, ValueError):
  153. return None
  154. if dt.tzinfo is None:
  155. dt = dt.replace(tzinfo=timezone.utc)
  156. return dt
  157. class _OrcaCredentials:
  158. """Lightweight bag for stored Orca Cloud credentials. We use a class
  159. rather than a dataclass so the helpers can mutate it as needed during
  160. JIT-refresh without rebuilding the whole object.
  161. ``pending_device_code`` / ``pending_interval`` / ``pending_at`` hold the
  162. in-flight device-code pairing state (reusing the ``orca_cloud_pending_*``
  163. columns that the old PKCE flow used for its verifier/state)."""
  164. __slots__ = (
  165. "token",
  166. "refresh_token",
  167. "expires_at",
  168. "email",
  169. "user_id",
  170. "pending_device_code",
  171. "pending_interval",
  172. "pending_at",
  173. )
  174. def __init__(self) -> None:
  175. self.token: str | None = None
  176. self.refresh_token: str | None = None
  177. self.expires_at: datetime | None = None
  178. self.email: str | None = None
  179. self.user_id: str | None = None
  180. self.pending_device_code: str | None = None
  181. self.pending_interval: str | None = None
  182. self.pending_at: datetime | None = None
  183. async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredentials:
  184. """Load stored Orca Cloud credentials for the caller (user-row when auth
  185. is enabled, Settings fallback when auth is disabled).
  186. Datetimes coming back from the User row are NAIVE on the Postgres side
  187. (asyncpg strips tzinfo for ``TIMESTAMP WITHOUT TIME ZONE`` columns) but
  188. represent UTC moments because that's what we stored. We attach
  189. ``tzinfo=UTC`` here so downstream comparisons against
  190. ``datetime.now(timezone.utc)`` don't get shifted by the host's local
  191. offset — ``naive_dt.astimezone(UTC)`` would assume local time, which on
  192. a UTC+2 host turns a 1-minute-old pending state into a 2h1m one and
  193. fires the 10-minute TTL guard immediately."""
  194. creds = _OrcaCredentials()
  195. if user is not None:
  196. creds.token = user.orca_cloud_token
  197. creds.refresh_token = user.orca_cloud_refresh_token
  198. creds.expires_at = _as_utc(user.orca_cloud_expires_at)
  199. creds.email = user.orca_cloud_email
  200. creds.user_id = user.orca_cloud_user_id
  201. creds.pending_device_code = user.orca_cloud_pending_verifier
  202. creds.pending_interval = user.orca_cloud_pending_state
  203. creds.pending_at = _as_utc(user.orca_cloud_pending_at)
  204. return creds
  205. result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
  206. raw = {s.key: s.value for s in result.scalars().all()}
  207. creds.token = raw.get(_SETTINGS_KEYS["token"])
  208. creds.refresh_token = raw.get(_SETTINGS_KEYS["refresh_token"])
  209. creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
  210. creds.email = raw.get(_SETTINGS_KEYS["email"])
  211. creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
  212. creds.pending_device_code = raw.get(_SETTINGS_KEYS["pending_device_code"])
  213. creds.pending_interval = raw.get(_SETTINGS_KEYS["pending_interval"])
  214. creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
  215. return creds
  216. async def _persist_pending_device(
  217. db: AsyncSession,
  218. user: User | None,
  219. device_code: str,
  220. interval: int,
  221. when: datetime,
  222. ) -> None:
  223. """Store the transient device-code state used by ``/device/start`` ->
  224. ``/device/poll``. The device_code is a secret kept server-side."""
  225. if user is not None:
  226. await db.execute(
  227. update(User)
  228. .where(User.id == user.id)
  229. .values(
  230. orca_cloud_pending_verifier=device_code,
  231. orca_cloud_pending_state=str(interval),
  232. orca_cloud_pending_at=when,
  233. )
  234. )
  235. await db.commit()
  236. return
  237. await _upsert_settings(
  238. db,
  239. {
  240. _SETTINGS_KEYS["pending_device_code"]: device_code,
  241. _SETTINGS_KEYS["pending_interval"]: str(interval),
  242. _SETTINGS_KEYS["pending_at"]: _iso(when),
  243. },
  244. )
  245. async def _clear_pending_device(db: AsyncSession, user: User | None) -> None:
  246. """Wipe just the pending device-code state (on terminal poll outcomes),
  247. leaving any existing tokens untouched."""
  248. if user is not None:
  249. await db.execute(
  250. update(User)
  251. .where(User.id == user.id)
  252. .values(
  253. orca_cloud_pending_verifier=None,
  254. orca_cloud_pending_state=None,
  255. orca_cloud_pending_at=None,
  256. )
  257. )
  258. await db.commit()
  259. return
  260. await _upsert_settings(
  261. db,
  262. {
  263. _SETTINGS_KEYS["pending_device_code"]: None,
  264. _SETTINGS_KEYS["pending_interval"]: None,
  265. _SETTINGS_KEYS["pending_at"]: None,
  266. },
  267. )
  268. async def _persist_tokens(
  269. db: AsyncSession,
  270. user: User | None,
  271. access_token: str,
  272. refresh_token: str | None,
  273. expires_at: datetime | None,
  274. email: str | None,
  275. user_id: str | None,
  276. ) -> None:
  277. """Atomically write the new access/refresh pair to whichever backing store
  278. the deployment uses. Also clears the pending device-code state on the same
  279. write, since by this point the pairing is complete."""
  280. if user is not None:
  281. await db.execute(
  282. update(User)
  283. .where(User.id == user.id)
  284. .values(
  285. orca_cloud_token=access_token,
  286. orca_cloud_refresh_token=refresh_token,
  287. orca_cloud_expires_at=expires_at,
  288. orca_cloud_email=email,
  289. orca_cloud_user_id=user_id,
  290. orca_cloud_pending_verifier=None,
  291. orca_cloud_pending_state=None,
  292. orca_cloud_pending_at=None,
  293. )
  294. )
  295. await db.commit()
  296. return
  297. await _upsert_settings(
  298. db,
  299. {
  300. _SETTINGS_KEYS["token"]: access_token,
  301. _SETTINGS_KEYS["refresh_token"]: refresh_token,
  302. _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
  303. _SETTINGS_KEYS["email"]: email,
  304. _SETTINGS_KEYS["user_id"]: user_id,
  305. _SETTINGS_KEYS["pending_device_code"]: None,
  306. _SETTINGS_KEYS["pending_interval"]: None,
  307. _SETTINGS_KEYS["pending_at"]: None,
  308. },
  309. )
  310. async def _persist_rotated_tokens(
  311. db: AsyncSession,
  312. user: User | None,
  313. access_token: str,
  314. refresh_token: str | None,
  315. expires_at: datetime | None,
  316. ) -> None:
  317. """Persist tokens after a refresh — does NOT touch email/user_id and does
  318. NOT touch the pending state (refresh happens long after pairing)."""
  319. if user is not None:
  320. await db.execute(
  321. update(User)
  322. .where(User.id == user.id)
  323. .values(
  324. orca_cloud_token=access_token,
  325. orca_cloud_refresh_token=refresh_token,
  326. orca_cloud_expires_at=expires_at,
  327. )
  328. )
  329. await db.commit()
  330. return
  331. await _upsert_settings(
  332. db,
  333. {
  334. _SETTINGS_KEYS["token"]: access_token,
  335. _SETTINGS_KEYS["refresh_token"]: refresh_token,
  336. _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
  337. },
  338. )
  339. async def _clear_credentials(db: AsyncSession, user: User | None) -> None:
  340. """Wipe everything Orca-related (tokens, identity, pending state)."""
  341. if user is not None:
  342. await db.execute(
  343. update(User)
  344. .where(User.id == user.id)
  345. .values(
  346. orca_cloud_token=None,
  347. orca_cloud_refresh_token=None,
  348. orca_cloud_expires_at=None,
  349. orca_cloud_email=None,
  350. orca_cloud_user_id=None,
  351. orca_cloud_pending_verifier=None,
  352. orca_cloud_pending_state=None,
  353. orca_cloud_pending_at=None,
  354. )
  355. )
  356. await db.commit()
  357. return
  358. result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
  359. for setting in result.scalars().all():
  360. await db.delete(setting)
  361. await db.commit()
  362. async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> None:
  363. """Idempotent upsert into the Settings table. ``None`` values delete the row."""
  364. keys = [k for k, _ in values.items()]
  365. result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
  366. existing = {s.key: s for s in result.scalars().all()}
  367. for key, value in values.items():
  368. row = existing.get(key)
  369. if value is None:
  370. if row is not None:
  371. await db.delete(row)
  372. continue
  373. if row is not None:
  374. row.value = value
  375. else:
  376. db.add(Settings(key=key, value=value))
  377. await db.commit()
  378. # ---------------------------------------------------------------------------
  379. # Authenticated service builder with JIT refresh
  380. # ---------------------------------------------------------------------------
  381. async def _build_authenticated_service(
  382. db: AsyncSession,
  383. user: User | None,
  384. ) -> OrcaCloudService:
  385. """Construct an :class:`OrcaCloudService` pre-populated with stored
  386. credentials. If the access token is within the refresh-leeway of expiry,
  387. proactively refresh and persist the new pair BEFORE returning, so the
  388. next API call doesn't time out mid-flight on an expired token.
  389. We don't lock around the refresh: Orca tolerates concurrent refreshes for
  390. ~60s (each racer gets its own valid pair on the same connection rather than
  391. a revoke), so a lost race here is harmless — last-write-wins on the stored
  392. pair, and whichever pair we keep is valid."""
  393. creds = await _load_credentials(db, user)
  394. if not creds.token:
  395. raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
  396. svc = OrcaCloudService()
  397. svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
  398. if not svc.is_authenticated:
  399. if not svc.refresh_token:
  400. raise HTTPException(
  401. status_code=401,
  402. detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
  403. )
  404. try:
  405. await svc.refresh()
  406. except OrcaCloudAuthError as e:
  407. # Refresh token was revoked or rotated out from under us. Clear
  408. # the stale credentials so the UI flips to disconnected.
  409. await _clear_credentials(db, user)
  410. raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
  411. except OrcaCloudError as e:
  412. raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
  413. # Persist new pair BEFORE returning. A crash between here and the
  414. # downstream API call would still leave the user with valid stored
  415. # tokens for the next request.
  416. await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
  417. return svc
  418. # ---------------------------------------------------------------------------
  419. # Route handlers
  420. # ---------------------------------------------------------------------------
  421. @router.post("/device/start", response_model=OrcaDeviceStartResponse)
  422. async def device_start(
  423. request: Request,
  424. db: AsyncSession = Depends(get_db),
  425. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  426. ):
  427. """Begin device pairing. Requests a device code from Orca, stores it
  428. server-side (the device_code is a secret and never leaves the backend),
  429. and returns the user_code + verification URIs + poll interval for the
  430. frontend to display and poll against."""
  431. svc = OrcaCloudService()
  432. # instance_url/label are display-only anti-phishing context on the approval
  433. # card. base_url may be off behind a reverse proxy, but it's harmless if so.
  434. instance_url = str(request.base_url).rstrip("/") or None
  435. try:
  436. data = await svc.request_device_code(instance_url=instance_url, instance_label="Bambuddy")
  437. except OrcaCloudAuthError as e:
  438. # invalid_client etc. — an operator misconfiguration, not user error.
  439. raise HTTPException(status_code=502, detail=f"Orca Cloud pairing is misconfigured: {e}") from e
  440. except OrcaCloudError as e:
  441. raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
  442. device_code = data.get("device_code")
  443. user_code = data.get("user_code")
  444. if not device_code or not user_code:
  445. raise HTTPException(status_code=502, detail="Orca Cloud returned an incomplete device-code response.")
  446. interval = int(data.get("interval") or 5)
  447. expires_in = int(data.get("expires_in") or DEVICE_CODE_TTL.total_seconds())
  448. await _persist_pending_device(db, current_user, device_code, interval, datetime.now(timezone.utc))
  449. return OrcaDeviceStartResponse(
  450. user_code=user_code,
  451. verification_uri=str(data.get("verification_uri") or ""),
  452. verification_uri_complete=str(data.get("verification_uri_complete") or ""),
  453. interval=interval,
  454. expires_in=expires_in,
  455. )
  456. @router.post("/device/poll", response_model=OrcaDevicePollResponse)
  457. async def device_poll(
  458. db: AsyncSession = Depends(get_db),
  459. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  460. ):
  461. """Poll the token endpoint once for the in-flight pairing. Returns an
  462. in-progress status while the user approves; on approval persists the token
  463. pair (clearing the pending state) and reports connected."""
  464. creds = await _load_credentials(db, current_user)
  465. if not creds.pending_device_code or not creds.pending_at:
  466. raise HTTPException(
  467. status_code=400,
  468. detail="No pending Orca Cloud pairing. Click Connect first to start the flow.",
  469. )
  470. # creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
  471. # normalization. Subtracting two aware UTC datetimes gives a real delta.
  472. age = datetime.now(timezone.utc) - creds.pending_at
  473. if age > DEVICE_CODE_TTL:
  474. await _clear_pending_device(db, current_user)
  475. return OrcaDevicePollResponse(status=DevicePoll.EXPIRED, connected=False)
  476. svc = OrcaCloudService()
  477. try:
  478. status, token_data = await svc.poll_token(creds.pending_device_code)
  479. except OrcaCloudError as e:
  480. raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
  481. if status in DevicePoll.ONGOING:
  482. return OrcaDevicePollResponse(status=status, connected=False)
  483. if status in DevicePoll.TERMINAL:
  484. # access_denied / expired_token — the attempt is dead; clear it so the
  485. # user starts fresh next time.
  486. await _clear_pending_device(db, current_user)
  487. return OrcaDevicePollResponse(status=status, connected=False)
  488. # COMPLETE — tokens issued and applied to svc. Introspect for the user_id
  489. # (the external API's /me doesn't return an email, so email stays None).
  490. user_id: str | None = None
  491. try:
  492. info = await svc.introspect()
  493. if isinstance(info, dict):
  494. user_id = _str_or_none(info.get("user_id"))
  495. except OrcaCloudError as e:
  496. # Don't fail the whole pairing over the side introspection call — we
  497. # have valid tokens, which is the load-bearing part.
  498. logger.warning("Orca Cloud introspection failed after successful pairing: %s", e)
  499. await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, None, user_id)
  500. return OrcaDevicePollResponse(status=DevicePoll.COMPLETE, connected=True, email=None, user_id=user_id)
  501. @router.get("/status", response_model=OrcaAuthStatusResponse)
  502. async def get_status(
  503. db: AsyncSession = Depends(get_db),
  504. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  505. ):
  506. """Return whether the caller has an Orca Cloud session stored, plus
  507. identifier details for display. Does NOT make a live API call."""
  508. creds = await _load_credentials(db, current_user)
  509. return OrcaAuthStatusResponse(
  510. connected=bool(creds.token),
  511. email=creds.email,
  512. user_id=creds.user_id,
  513. )
  514. @router.post("/logout")
  515. async def logout(
  516. db: AsyncSession = Depends(get_db),
  517. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  518. ):
  519. """Clear stored Orca Cloud credentials. Does not call Orca's disconnect
  520. endpoint (the user can revoke server-side from Orca Cloud's own settings;
  521. Bambuddy will no longer have the token to use either way)."""
  522. await _clear_credentials(db, current_user)
  523. return {"success": True}
  524. @router.get("/profiles", response_model=OrcaProfileListResponse)
  525. async def list_profiles(
  526. db: AsyncSession = Depends(get_db),
  527. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  528. ):
  529. """Return profile metadata grouped by type (``filament`` / ``printer``
  530. / ``process``), matching the ``SlicerSettingsResponse`` shape the
  531. Bambu Cloud tab consumes. This lets the frontend render Orca profiles
  532. with the same visual components — same cards, same filter bar, same
  533. grouping — without separate UI code paths."""
  534. svc = await _build_authenticated_service(db, current_user)
  535. try:
  536. raw_profiles = await svc.list_profiles()
  537. except OrcaCloudAuthError as e:
  538. raise HTTPException(status_code=401, detail=str(e)) from e
  539. except OrcaCloudError as e:
  540. raise HTTPException(status_code=502, detail=str(e)) from e
  541. grouped: dict[str, list[OrcaProfileMeta]] = {"filament": [], "printer": [], "process": []}
  542. # Log any unknown content.type values we silently drop, so a future
  543. # change in Orca's type vocabulary surfaces in the logs rather than
  544. # quietly losing profiles.
  545. unknown_types: dict[str, int] = {}
  546. for entry in raw_profiles:
  547. setting = _orca_to_setting(entry)
  548. if setting is None:
  549. content = entry.get("content") if isinstance(entry, dict) else None
  550. raw_type = (content.get("type") if isinstance(content, dict) else None) or "<missing>"
  551. unknown_types[str(raw_type)] = unknown_types.get(str(raw_type), 0) + 1
  552. continue
  553. grouped[setting.type].append(setting)
  554. if unknown_types:
  555. logger.warning(
  556. "Orca Cloud profile list dropped %d profiles with unmapped content.type values: %s",
  557. sum(unknown_types.values()),
  558. unknown_types,
  559. )
  560. return OrcaProfileListResponse(**grouped)
  561. @router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail)
  562. async def get_profile(
  563. profile_id: str,
  564. db: AsyncSession = Depends(get_db),
  565. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  566. ):
  567. """Fetch a single profile's full content, shaped like
  568. ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
  569. unchanged. The inner ``setting`` field is the raw slicer-format JSON
  570. Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
  571. BambuStudio fork."""
  572. svc = await _build_authenticated_service(db, current_user)
  573. try:
  574. profile = await svc.get_profile(profile_id)
  575. except OrcaCloudAuthError as e:
  576. raise HTTPException(status_code=401, detail=str(e)) from e
  577. except OrcaCloudError as e:
  578. if "not found" in str(e).lower():
  579. raise HTTPException(status_code=404, detail=str(e)) from e
  580. raise HTTPException(status_code=502, detail=str(e)) from e
  581. content = profile.get("content") if isinstance(profile, dict) else None
  582. if not isinstance(content, dict):
  583. content = {}
  584. orca_type = str(content.get("type", ""))
  585. bambu_type = _ORCA_TYPE_TO_BAMBU.get(orca_type, orca_type)
  586. update_time = profile.get("updated_time") if isinstance(profile, dict) else None
  587. return OrcaProfileDetail(
  588. setting_id=str(profile_id),
  589. name=str(profile.get("name") if isinstance(profile, dict) else "") or str(profile_id),
  590. type=bambu_type,
  591. version=_str_or_none(content.get("version")),
  592. base_id=_str_or_none(content.get("inherits") or content.get("base_id")),
  593. update_time=str(update_time) if update_time is not None else None,
  594. setting=content,
  595. )