Jarvis2345 commited on
Commit
0a02f47
·
verified ·
1 Parent(s): 243637a

deploy(S4): Blender headless pipeline + WebAR client + backend fixes

Browse files
backend/main.py CHANGED
@@ -79,6 +79,7 @@ from backend.routes.internet_routes import router as internet_router
79
  from backend.routes.sentinel_routes import router as sentinel_router
80
  from backend.routers.gaming_routes import router as gaming_router
81
  from backend.routes.model_proxy_routes import router as model_proxy_router # AR-FIX-SESSION
 
82
 
83
  from backend.services.voice_service import start_wake_word_loop
84
  from backend.services.usb_monitor import start_usb_monitor
@@ -148,6 +149,11 @@ app.include_router(model_proxy_router, prefix="/api") # AR-FIX-SESSION: model p
148
  # /api/ar_config, /api/ar_task, /api/ar_scene_patch — the backend only ever mounted
149
  # them under /xr, so every OMEGA link from the AR client 404'd. Serve both prefixes.
150
  app.include_router(xr_router, prefix="/api", dependencies=[Depends(verify_token)])
 
 
 
 
 
151
 
152
  # S4: serve generated 3D models. xr_tools.push_model_to_ar_scene has always broadcast
153
  # /static/models3d/<file> URLs, but nothing ever mounted them — every spawned GLB 404'd.
 
79
  from backend.routes.sentinel_routes import router as sentinel_router
80
  from backend.routers.gaming_routes import router as gaming_router
81
  from backend.routes.model_proxy_routes import router as model_proxy_router # AR-FIX-SESSION
82
+ from backend.routes.mobile_bridge_routes import router as mobile_bridge_router # S4: Guardian /api compat
83
 
84
  from backend.services.voice_service import start_wake_word_loop
85
  from backend.services.usb_monitor import start_usb_monitor
 
149
  # /api/ar_config, /api/ar_task, /api/ar_scene_patch — the backend only ever mounted
150
  # them under /xr, so every OMEGA link from the AR client 404'd. Serve both prefixes.
151
  app.include_router(xr_router, prefix="/api", dependencies=[Depends(verify_token)])
152
+ # S4: the JARVIS Mobile Guardian APK's remaining /api/* REST contract (status, caps,
153
+ # link_info, pair_confirm, command, phone_observe, jarvis/mobile_event,
154
+ # max_autonomy/task) had no cloud handler and 404'd against the Space. This compat
155
+ # router serves exactly those, delegating to modules/max_autonomy + phone/crypto.
156
+ app.include_router(mobile_bridge_router, prefix="/api", dependencies=[Depends(verify_token)])
157
 
158
  # S4: serve generated 3D models. xr_tools.push_model_to_ar_scene has always broadcast
159
  # /static/models3d/<file> URLs, but nothing ever mounted them — every spawned GLB 404'd.
backend/routes/mobile_bridge_routes.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """
4
+ backend/routes/mobile_bridge_routes.py — S4 Guardian ⇄ cloud REST compatibility.
5
+
6
+ The JARVIS Mobile Guardian APK (phone/jarvis-mobile-guardian) speaks a fixed
7
+ `/api/*` REST contract (see network/ApiClient.kt). On the local PC that contract
8
+ is served by phone/local_server.py on :7474; on the cloud it was only partially
9
+ served — xr_router@/api covers ar_task/ar_scene/ar_game_state/ar_game_builder,
10
+ but eight endpoints (pair_confirm, status, caps, link_info, command,
11
+ phone_observe, jarvis/mobile_event, max_autonomy/task) had no cloud handler, so
12
+ Guardian's status card, capability grid, AR-URL fetch, command box, telemetry
13
+ sync and autonomy tasks all 404'd against jarvis-cloud.hf.space.
14
+
15
+ This router fills exactly those gaps, mounted at /api behind verify_token
16
+ (Guardian sends `Authorization: Bearer <JARVIS_CLOUD_TOKEN>` on every call). It
17
+ delegates to the real cross-device coordinator (modules/max_autonomy.py — the
18
+ module explicitly built as "the single backend surface for APK, PC AR, browser
19
+ panels and Guardian heartbeats") and the shared AES-GCM helpers (phone/crypto.py)
20
+ rather than re-implementing behaviour, so cloud and PC stay in lock-step.
21
+
22
+ Encrypted /api/command: Guardian derives its AES key as SHA-256(passphrase)
23
+ (CryptoBox.deriveKey). phone/crypto.LinkConfig.key_bytes() derives the same way,
24
+ so a cloud pairing whose passphrase is the JARVIS_CLOUD_TOKEN lets the Space
25
+ decrypt and route the command through the safety-gated autonomy engine.
26
+ """
27
+
28
+ import base64
29
+ import hashlib
30
+ import logging
31
+ import os
32
+ import secrets
33
+ import time
34
+ from typing import Any
35
+
36
+ from fastapi import APIRouter, Request
37
+
38
+ router = APIRouter()
39
+ log = logging.getLogger(__name__)
40
+
41
+ _BOOT_TS = time.time()
42
+
43
+
44
+ # --------------------------------------------------------------------------- #
45
+ # helpers
46
+ # --------------------------------------------------------------------------- #
47
+ def _master_token() -> str:
48
+ try:
49
+ from backend.dependencies.auth import get_or_create_master_token
50
+ return get_or_create_master_token()
51
+ except Exception:
52
+ return os.environ.get("JARVIS_CLOUD_TOKEN", "").strip()
53
+
54
+
55
+ def _uptime_str() -> str:
56
+ secs = int(time.time() - _BOOT_TS)
57
+ h, rem = divmod(secs, 3600)
58
+ m, s = divmod(rem, 60)
59
+ if h:
60
+ return f"{h}h {m}m"
61
+ if m:
62
+ return f"{m}m {s}s"
63
+ return f"{s}s"
64
+
65
+
66
+ def _cpu_ram() -> tuple[float | None, float | None]:
67
+ try:
68
+ import psutil # type: ignore
69
+ return (
70
+ float(psutil.cpu_percent(interval=None)),
71
+ float(psutil.virtual_memory().percent),
72
+ )
73
+ except Exception:
74
+ return (None, None)
75
+
76
+
77
+ def _base_url(request: Request) -> str:
78
+ # Honour reverse-proxy host so the AR URL points at the public Space, not the
79
+ # internal 127.0.0.1:7860 uvicorn bind.
80
+ host = request.headers.get("x-forwarded-host") or request.headers.get("host")
81
+ if host:
82
+ proto = request.headers.get("x-forwarded-proto") or request.url.scheme
83
+ return f"{proto}://{host}".rstrip("/")
84
+ return str(request.base_url).rstrip("/")
85
+
86
+
87
+ async def _json_body(request: Request) -> dict[str, Any]:
88
+ try:
89
+ data = await request.json()
90
+ return data if isinstance(data, dict) else {}
91
+ except Exception:
92
+ return {}
93
+
94
+
95
+ def _candidate_keys() -> list[bytes]:
96
+ """AES keys to try when decrypting a Guardian command envelope, best-first."""
97
+ keys: list[bytes] = []
98
+ token = _master_token()
99
+ if token:
100
+ keys.append(hashlib.sha256(token.encode("utf-8")).digest())
101
+ try:
102
+ from phone.crypto import load_or_create_link
103
+ keys.append(load_or_create_link().key_bytes())
104
+ except Exception as _exc:
105
+ log.debug("link key unavailable: %s", _exc)
106
+ return keys
107
+
108
+
109
+ # --------------------------------------------------------------------------- #
110
+ # pairing / status / capabilities / link info
111
+ # --------------------------------------------------------------------------- #
112
+ @router.post("/pair_confirm")
113
+ async def pair_confirm(request: Request) -> dict[str, Any]:
114
+ body = await _json_body(request)
115
+ device_name = str(body.get("device_name") or "android").strip()[:64]
116
+ passphrase = str(body.get("passphrase") or "").strip()
117
+ # The Bearer token already authenticated the request; pairing simply records
118
+ # the device. If the passphrase matches the cloud token, encrypted commands
119
+ # will also decrypt cleanly (see /command).
120
+ token = _master_token()
121
+ keyed = bool(token) and secrets.compare_digest(passphrase, token)
122
+ try:
123
+ from phone.pairing import mark_paired
124
+ mark_paired(device_name=device_name or "android")
125
+ except Exception as _exc:
126
+ log.debug("mark_paired skipped on cloud: %s", _exc)
127
+ return {"ok": True, "paired": True, "keyed": keyed, "device_name": device_name}
128
+
129
+
130
+ @router.get("/status")
131
+ async def status() -> dict[str, Any]:
132
+ cpu, ram = _cpu_ram()
133
+ location = "cloud" if os.environ.get("SPACE_ID") else "pc"
134
+ return {
135
+ "ok": True,
136
+ "status": "online",
137
+ "version": "1.0.0",
138
+ "uptime": _uptime_str(),
139
+ "mode": location,
140
+ "activated": True,
141
+ "assistant_name": "JARVIS",
142
+ "cpu": cpu,
143
+ "ram": ram,
144
+ }
145
+
146
+
147
+ @router.get("/caps")
148
+ async def caps() -> dict[str, Any]:
149
+ """Flatten the capability catalogue to the {capabilities:[str]} shape the APK
150
+ deserializes (CapabilityList)."""
151
+ labels: list[str] = []
152
+ try:
153
+ from phone.capabilities import capabilities_payload
154
+ for category in capabilities_payload().get("categories", []):
155
+ for item in category.get("items", []):
156
+ label = str(item.get("label") or item.get("cmd") or "").strip()
157
+ if label:
158
+ labels.append(label)
159
+ except Exception as _exc:
160
+ log.debug("capabilities unavailable: %s", _exc)
161
+ return {"ok": True, "capabilities": labels}
162
+
163
+
164
+ @router.get("/link_info")
165
+ async def link_info(request: Request) -> dict[str, Any]:
166
+ base = _base_url(request)
167
+ ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
168
+ ar_url = f"{base}/webar/"
169
+ return {
170
+ "ok": True,
171
+ "assistant": "JARVIS",
172
+ "host": base,
173
+ "pwa": ar_url,
174
+ "pwa_https": ar_url,
175
+ "ar": ar_url,
176
+ "ar_https": ar_url,
177
+ "ar_engine": "8thwall",
178
+ "ar_runtime": "webxr",
179
+ "scene_ws": f"{ws_base}/scene/ws",
180
+ }
181
+
182
+
183
+ # --------------------------------------------------------------------------- #
184
+ # command (encrypted) — routed through the safety-gated autonomy engine
185
+ # --------------------------------------------------------------------------- #
186
+ @router.post("/command")
187
+ async def command(request: Request) -> dict[str, Any]:
188
+ envelope = await _json_body(request)
189
+ if not (envelope.get("nonce") and envelope.get("ciphertext")):
190
+ return {"ok": False, "error": "expected encrypted envelope {nonce, ciphertext}"}
191
+
192
+ plaintext: dict[str, Any] | None = None
193
+ try:
194
+ from phone.crypto import decrypt_json
195
+ for key in _candidate_keys():
196
+ try:
197
+ plaintext = decrypt_json(envelope, key)
198
+ break
199
+ except Exception:
200
+ continue
201
+ except Exception as _exc:
202
+ log.debug("decrypt_json import failed: %s", _exc)
203
+
204
+ if not isinstance(plaintext, dict):
205
+ # Wrong pairing passphrase for the cloud: pair with the cloud access key
206
+ # so the Space can derive the same AES key.
207
+ return {"ok": False, "error": "decrypt failed — pair using your cloud access key"}
208
+
209
+ cmd = str(plaintext.get("cmd") or "").strip()
210
+ if not cmd:
211
+ return {"ok": False, "error": "missing cmd"}
212
+ device_name = str(plaintext.get("device_name") or "").strip()[:80]
213
+
214
+ try:
215
+ from modules.max_autonomy import execute_task
216
+ # This router only ever serves the *cloud* path (on the LAN, Guardian hits
217
+ # phone/local_server.py:7474, which executes for real against the PC). The
218
+ # cloud container has no PC to drive, so the honest behaviour is to run the
219
+ # command through the safety-gated coordinator in record+sync mode: it is
220
+ # logged, risk-assessed and pushed to the AR HUD, without faking hardware
221
+ # control. Real desktop actions stay on the paired-PC channel.
222
+ result = execute_task(
223
+ cmd,
224
+ source="guardian_command",
225
+ device_name=device_name,
226
+ context={"record_only": True},
227
+ )
228
+ spoken = list(result.response or [])
229
+ return {"ok": bool(result.ok), "spoken": spoken, "status": result.status, "action": result.action}
230
+ except Exception as exc:
231
+ log.warning("guardian command routing failed: %s", exc)
232
+ return {"ok": False, "spoken": [f"Command failed: {str(exc)[:160]}"]}
233
+
234
+
235
+ # --------------------------------------------------------------------------- #
236
+ # telemetry / events / autonomy
237
+ # --------------------------------------------------------------------------- #
238
+ @router.post("/phone_observe")
239
+ async def phone_observe(request: Request) -> dict[str, Any]:
240
+ body = await _json_body(request)
241
+ device_name = str(body.get("device_name") or "").strip()[:80]
242
+ payload = {k: v for k, v in body.items() if k != "device_name"}
243
+ try:
244
+ from modules.max_autonomy import record_mobile_event
245
+ record_mobile_event("phone_observe", payload, device_name=device_name)
246
+ except Exception as exc:
247
+ log.debug("phone_observe record failed: %s", exc)
248
+ return {"ok": False, "error": str(exc)[:160]}
249
+ return {"ok": True}
250
+
251
+
252
+ @router.post("/jarvis/mobile_event")
253
+ async def mobile_event(request: Request) -> dict[str, Any]:
254
+ body = await _json_body(request)
255
+ kind = str(body.get("kind") or "event").strip()[:80]
256
+ device_name = str(body.get("device_name") or "").strip()[:80]
257
+ payload = body.get("payload")
258
+ if not isinstance(payload, dict):
259
+ payload = {k: v for k, v in body.items() if k not in ("kind", "device_name", "ts")}
260
+ try:
261
+ from modules.max_autonomy import record_mobile_event
262
+ record_mobile_event(kind, payload, device_name=device_name)
263
+ except Exception as exc:
264
+ log.debug("mobile_event record failed: %s", exc)
265
+ return {"ok": False, "error": str(exc)[:160]}
266
+ return {"ok": True}
267
+
268
+
269
+ @router.post("/max_autonomy/task")
270
+ async def max_autonomy_task(request: Request) -> dict[str, Any]:
271
+ body = await _json_body(request)
272
+ task = str(body.get("task") or "").strip()
273
+ source = str(body.get("source") or "jarvis-native-android").strip()[:80]
274
+ device_name = str(body.get("device_name") or "").strip()[:80]
275
+ confirmed = bool(body.get("confirmed") or False)
276
+ context = body.get("context") if isinstance(body.get("context"), dict) else {}
277
+ try:
278
+ from modules.max_autonomy import execute_task
279
+ result = execute_task(
280
+ task,
281
+ source=source,
282
+ device_name=device_name,
283
+ context=context,
284
+ confirmed=confirmed,
285
+ )
286
+ return result.as_dict()
287
+ except Exception as exc:
288
+ log.warning("max_autonomy task failed: %s", exc)
289
+ return {
290
+ "ok": False,
291
+ "status": "error",
292
+ "task": task,
293
+ "action": "error",
294
+ "message": f"Autonomy task failed: {str(exc)[:160]}",
295
+ "requires_confirmation": False,
296
+ "blocked": False,
297
+ "risk": {},
298
+ "plan": [],
299
+ "response": [f"Autonomy task failed: {str(exc)[:160]}"],
300
+ }
phone/capabilities.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """
4
+ phone/capabilities.py
5
+
6
+ Single source of truth for what the phone client (APK) can drive easily.
7
+ This stays free: it's just a JSON description of commands already implemented in core/commands.py.
8
+ """
9
+
10
+ from typing import Any
11
+
12
+
13
+ def capabilities_payload() -> dict[str, Any]:
14
+ # Keep this compact and practical. These are *commands*, not fake UI.
15
+ return {
16
+ "ok": True,
17
+ "version": 1,
18
+ "categories": [
19
+ {
20
+ "name": "Network",
21
+ "items": [
22
+ {"label": "WiFi Toggle", "cmd": "wifi"},
23
+ {"label": "Bluetooth Toggle", "cmd": "bluetooth"},
24
+ {"label": "VPN Status", "cmd": "vpn status"},
25
+ {"label": "Internet Check", "cmd": "internet check"},
26
+ {"label": "Ping 1.1.1.1", "cmd": "ping 1.1.1.1"},
27
+ {"label": "Speed Test", "cmd": "speed test"},
28
+ ],
29
+ },
30
+ {
31
+ "name": "Audio",
32
+ "items": [
33
+ {"label": "Volume Up", "cmd": "volume up"},
34
+ {"label": "Volume Down", "cmd": "volume down"},
35
+ {"label": "Mute", "cmd": "mute"},
36
+ {"label": "Audio Reset", "cmd": "audio reset"},
37
+ {"label": "EQ Preset", "cmd": "eq preset"},
38
+ ],
39
+ },
40
+ {
41
+ "name": "Display",
42
+ "items": [
43
+ {"label": "Brightness Cycle", "cmd": "cycle brightness"},
44
+ {"label": "Night Light", "cmd": "night light"},
45
+ {"label": "HDR Toggle", "cmd": "toggle hdr"},
46
+ {"label": "Resolution/Hz Cycle", "cmd": "cycle resolution"},
47
+ {"label": "Color Profile", "cmd": "cycle color profile"},
48
+ ],
49
+ },
50
+ {
51
+ "name": "System",
52
+ "items": [
53
+ {"label": "System Report", "cmd": "system report"},
54
+ {"label": "Lock PC", "cmd": "lock"},
55
+ {"label": "Snapshot", "cmd": "snapshot"},
56
+ {"label": "Steam Fix", "cmd": "steam fix"},
57
+ ],
58
+ },
59
+ {
60
+ "name": "Windows",
61
+ "items": [
62
+ {"label": "Tile Windows", "cmd": "tile windows"},
63
+ {"label": "Cascade Windows", "cmd": "cascade windows"},
64
+ {"label": "Snap Left", "cmd": "snap left"},
65
+ {"label": "Snap Right", "cmd": "snap right"},
66
+ {"label": "Move To Next Monitor", "cmd": "move to next monitor"},
67
+ ],
68
+ },
69
+ {
70
+ "name": "Modes",
71
+ "items": [
72
+ {"label": "Stark", "cmd": "stark mode"},
73
+ {"label": "Tactical", "cmd": "tactical mode"},
74
+ {"label": "Gaming", "cmd": "gaming mode"},
75
+ {"label": "Chill", "cmd": "chill mode"},
76
+ {"label": "Ghost", "cmd": "ghost mode"},
77
+ {"label": "Overdrive", "cmd": "overdrive mode"},
78
+ ],
79
+ },
80
+ ],
81
+ }
82
+
phone/crypto.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import hashlib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ LINK_PATH = ROOT / "data" / "phone_link.json"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class LinkConfig:
19
+ # Either a raw key (legacy) or a human passphrase (preferred for phone pairing).
20
+ key_b64: str = ""
21
+ passphrase: str = ""
22
+ device_name: str = "phone"
23
+ # Optional: allowlist of Telegram user id(s) as strings.
24
+ telegram_allow: list[str] | None = None
25
+
26
+ def key_bytes(self) -> bytes:
27
+ if self.passphrase:
28
+ return hashlib.sha256(self.passphrase.encode("utf-8")).digest()
29
+ return base64.b64decode(self.key_b64.encode("utf-8"))
30
+
31
+
32
+ def load_or_create_link() -> LinkConfig:
33
+ LINK_PATH.parent.mkdir(parents=True, exist_ok=True)
34
+ if not LINK_PATH.exists():
35
+ data = {}
36
+ else:
37
+ try:
38
+ data = json.loads(LINK_PATH.read_text(encoding="utf-8"))
39
+ except Exception:
40
+ data = {}
41
+
42
+ # Ensure passphrase exists (preferred pairing method). Migrate older installs.
43
+ if not str(data.get("passphrase", "")).strip():
44
+ data["passphrase"] = base64.b32encode(os.urandom(10)).decode("utf-8").rstrip("=").lower()
45
+ # Keep legacy key_b64 if present; otherwise empty.
46
+ data.setdefault("key_b64", "")
47
+ data.setdefault("device_name", "phone")
48
+ data.setdefault("telegram_allow", [])
49
+
50
+ try:
51
+ LINK_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
52
+ except Exception:
53
+ pass
54
+ return LinkConfig(
55
+ key_b64=str(data.get("key_b64", "")),
56
+ passphrase=str(data.get("passphrase", "")),
57
+ device_name=str(data.get("device_name", "phone")),
58
+ telegram_allow=list(data.get("telegram_allow", []) or []),
59
+ )
60
+
61
+
62
+ def encrypt_json(obj: dict, key: bytes) -> dict:
63
+ aes = AESGCM(key)
64
+ nonce = os.urandom(12)
65
+ pt = json.dumps(obj, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
66
+ ct = aes.encrypt(nonce, pt, None)
67
+ return {"nonce": base64.b64encode(nonce).decode("utf-8"), "ciphertext": base64.b64encode(ct).decode("utf-8")}
68
+
69
+
70
+ def decrypt_json(payload: dict, key: bytes) -> dict:
71
+ aes = AESGCM(key)
72
+ nonce = base64.b64decode(str(payload.get("nonce", "")).encode("utf-8"))
73
+ ct = base64.b64decode(str(payload.get("ciphertext", "")).encode("utf-8"))
74
+ pt = aes.decrypt(nonce, ct, None)
75
+ return json.loads(pt.decode("utf-8"))
phone/pairing.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ PAIR_PATH = ROOT / "data" / "phone_pairing.json"
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class PairStatus:
15
+ paired: bool
16
+ device_name: str
17
+ paired_at: float
18
+
19
+
20
+ def mark_paired(device_name: str = "android") -> None:
21
+ PAIR_PATH.parent.mkdir(parents=True, exist_ok=True)
22
+ payload = {"paired": True, "device_name": str(device_name or "android"), "paired_at": float(time.time())}
23
+ try:
24
+ PAIR_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
25
+ except Exception:
26
+ pass
27
+
28
+
29
+ def read_pair_status() -> PairStatus:
30
+ try:
31
+ if not PAIR_PATH.exists():
32
+ return PairStatus(False, "", 0.0)
33
+ data = json.loads(PAIR_PATH.read_text(encoding="utf-8"))
34
+ if not isinstance(data, dict):
35
+ return PairStatus(False, "", 0.0)
36
+ return PairStatus(bool(data.get("paired")), str(data.get("device_name") or ""), float(data.get("paired_at") or 0.0))
37
+ except Exception:
38
+ return PairStatus(False, "", 0.0)
39
+