machinaos 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/.env.template +71 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/bin/cli.js +159 -0
  5. package/client/.dockerignore +45 -0
  6. package/client/Dockerfile +68 -0
  7. package/client/eslint.config.js +29 -0
  8. package/client/index.html +13 -0
  9. package/client/nginx.conf +66 -0
  10. package/client/package.json +48 -0
  11. package/client/src/App.tsx +27 -0
  12. package/client/src/Dashboard.tsx +1173 -0
  13. package/client/src/ParameterPanel.tsx +301 -0
  14. package/client/src/components/AIAgentNode.tsx +321 -0
  15. package/client/src/components/APIKeyValidator.tsx +118 -0
  16. package/client/src/components/ClaudeChatModelNode.tsx +18 -0
  17. package/client/src/components/ConditionalEdge.tsx +189 -0
  18. package/client/src/components/CredentialsModal.tsx +306 -0
  19. package/client/src/components/EdgeConditionEditor.tsx +443 -0
  20. package/client/src/components/GeminiChatModelNode.tsx +18 -0
  21. package/client/src/components/GenericNode.tsx +357 -0
  22. package/client/src/components/LocationParameterPanel.tsx +154 -0
  23. package/client/src/components/ModelNode.tsx +286 -0
  24. package/client/src/components/OpenAIChatModelNode.tsx +18 -0
  25. package/client/src/components/OutputPanel.tsx +471 -0
  26. package/client/src/components/ParameterRenderer.tsx +1874 -0
  27. package/client/src/components/SkillEditorModal.tsx +417 -0
  28. package/client/src/components/SquareNode.tsx +797 -0
  29. package/client/src/components/StartNode.tsx +250 -0
  30. package/client/src/components/ToolkitNode.tsx +365 -0
  31. package/client/src/components/TriggerNode.tsx +463 -0
  32. package/client/src/components/auth/LoginPage.tsx +247 -0
  33. package/client/src/components/auth/ProtectedRoute.tsx +59 -0
  34. package/client/src/components/base/BaseChatModelNode.tsx +271 -0
  35. package/client/src/components/icons/AIProviderIcons.tsx +50 -0
  36. package/client/src/components/maps/GoogleMapsPicker.tsx +137 -0
  37. package/client/src/components/maps/MapsPreviewPanel.tsx +110 -0
  38. package/client/src/components/maps/index.ts +26 -0
  39. package/client/src/components/parameterPanel/InputSection.tsx +1094 -0
  40. package/client/src/components/parameterPanel/LocationPanelLayout.tsx +65 -0
  41. package/client/src/components/parameterPanel/MapsSection.tsx +92 -0
  42. package/client/src/components/parameterPanel/MiddleSection.tsx +571 -0
  43. package/client/src/components/parameterPanel/OutputSection.tsx +81 -0
  44. package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +82 -0
  45. package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -0
  46. package/client/src/components/parameterPanel/index.ts +42 -0
  47. package/client/src/components/shared/DataPanel.tsx +142 -0
  48. package/client/src/components/shared/JSONTreeRenderer.tsx +106 -0
  49. package/client/src/components/ui/AIResultModal.tsx +204 -0
  50. package/client/src/components/ui/AndroidSettingsPanel.tsx +401 -0
  51. package/client/src/components/ui/CodeEditor.tsx +81 -0
  52. package/client/src/components/ui/CollapsibleSection.tsx +88 -0
  53. package/client/src/components/ui/ComponentItem.tsx +154 -0
  54. package/client/src/components/ui/ComponentPalette.tsx +321 -0
  55. package/client/src/components/ui/ConsolePanel.tsx +1074 -0
  56. package/client/src/components/ui/ErrorBoundary.tsx +196 -0
  57. package/client/src/components/ui/InputNodesPanel.tsx +204 -0
  58. package/client/src/components/ui/MapSelector.tsx +314 -0
  59. package/client/src/components/ui/Modal.tsx +149 -0
  60. package/client/src/components/ui/NodeContextMenu.tsx +192 -0
  61. package/client/src/components/ui/NodeOutputPanel.tsx +1150 -0
  62. package/client/src/components/ui/OutputDisplayPanel.tsx +381 -0
  63. package/client/src/components/ui/SettingsPanel.tsx +243 -0
  64. package/client/src/components/ui/TopToolbar.tsx +736 -0
  65. package/client/src/components/ui/WhatsAppSettingsPanel.tsx +345 -0
  66. package/client/src/components/ui/WorkflowSidebar.tsx +294 -0
  67. package/client/src/config/antdTheme.ts +186 -0
  68. package/client/src/config/api.ts +54 -0
  69. package/client/src/contexts/AuthContext.tsx +221 -0
  70. package/client/src/contexts/ThemeContext.tsx +42 -0
  71. package/client/src/contexts/WebSocketContext.tsx +1971 -0
  72. package/client/src/factories/baseChatModelFactory.ts +256 -0
  73. package/client/src/hooks/useAndroidOperations.ts +164 -0
  74. package/client/src/hooks/useApiKeyValidation.ts +107 -0
  75. package/client/src/hooks/useApiKeys.ts +238 -0
  76. package/client/src/hooks/useAppTheme.ts +17 -0
  77. package/client/src/hooks/useComponentPalette.ts +51 -0
  78. package/client/src/hooks/useCopyPaste.ts +155 -0
  79. package/client/src/hooks/useDragAndDrop.ts +124 -0
  80. package/client/src/hooks/useDragVariable.ts +88 -0
  81. package/client/src/hooks/useExecution.ts +313 -0
  82. package/client/src/hooks/useParameterPanel.ts +176 -0
  83. package/client/src/hooks/useReactFlowNodes.ts +189 -0
  84. package/client/src/hooks/useToolSchema.ts +209 -0
  85. package/client/src/hooks/useWhatsApp.ts +196 -0
  86. package/client/src/hooks/useWorkflowManagement.ts +46 -0
  87. package/client/src/index.css +315 -0
  88. package/client/src/main.tsx +19 -0
  89. package/client/src/nodeDefinitions/aiAgentNodes.ts +336 -0
  90. package/client/src/nodeDefinitions/aiModelNodes.ts +340 -0
  91. package/client/src/nodeDefinitions/androidDeviceNodes.ts +140 -0
  92. package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -0
  93. package/client/src/nodeDefinitions/chatNodes.ts +135 -0
  94. package/client/src/nodeDefinitions/codeNodes.ts +54 -0
  95. package/client/src/nodeDefinitions/documentNodes.ts +379 -0
  96. package/client/src/nodeDefinitions/index.ts +15 -0
  97. package/client/src/nodeDefinitions/locationNodes.ts +463 -0
  98. package/client/src/nodeDefinitions/schedulerNodes.ts +220 -0
  99. package/client/src/nodeDefinitions/skillNodes.ts +211 -0
  100. package/client/src/nodeDefinitions/toolNodes.ts +198 -0
  101. package/client/src/nodeDefinitions/utilityNodes.ts +284 -0
  102. package/client/src/nodeDefinitions/whatsappNodes.ts +865 -0
  103. package/client/src/nodeDefinitions/workflowNodes.ts +41 -0
  104. package/client/src/nodeDefinitions.ts +104 -0
  105. package/client/src/schemas/workflowSchema.ts +264 -0
  106. package/client/src/services/dynamicParameterService.ts +96 -0
  107. package/client/src/services/execution/aiAgentExecutionService.ts +35 -0
  108. package/client/src/services/executionService.ts +232 -0
  109. package/client/src/services/workflowApi.ts +91 -0
  110. package/client/src/store/useAppStore.ts +582 -0
  111. package/client/src/styles/theme.ts +508 -0
  112. package/client/src/styles/zIndex.ts +17 -0
  113. package/client/src/types/ComponentTypes.ts +39 -0
  114. package/client/src/types/EdgeCondition.ts +231 -0
  115. package/client/src/types/INodeProperties.ts +288 -0
  116. package/client/src/types/NodeTypes.ts +28 -0
  117. package/client/src/utils/formatters.ts +33 -0
  118. package/client/src/utils/googleMapsLoader.ts +140 -0
  119. package/client/src/utils/locationUtils.ts +85 -0
  120. package/client/src/utils/nodeUtils.ts +31 -0
  121. package/client/src/utils/workflow.ts +30 -0
  122. package/client/src/utils/workflowExport.ts +120 -0
  123. package/client/src/vite-env.d.ts +12 -0
  124. package/client/tailwind.config.js +60 -0
  125. package/client/tsconfig.json +25 -0
  126. package/client/tsconfig.node.json +11 -0
  127. package/client/vite.config.js +35 -0
  128. package/docker-compose.prod.yml +107 -0
  129. package/docker-compose.yml +104 -0
  130. package/docs-MachinaOs/README.md +85 -0
  131. package/docs-MachinaOs/deployment/docker.mdx +228 -0
  132. package/docs-MachinaOs/deployment/production.mdx +345 -0
  133. package/docs-MachinaOs/docs.json +75 -0
  134. package/docs-MachinaOs/faq.mdx +309 -0
  135. package/docs-MachinaOs/favicon.svg +5 -0
  136. package/docs-MachinaOs/installation.mdx +160 -0
  137. package/docs-MachinaOs/introduction.mdx +114 -0
  138. package/docs-MachinaOs/logo/dark.svg +6 -0
  139. package/docs-MachinaOs/logo/light.svg +6 -0
  140. package/docs-MachinaOs/nodes/ai-agent.mdx +216 -0
  141. package/docs-MachinaOs/nodes/ai-models.mdx +240 -0
  142. package/docs-MachinaOs/nodes/android.mdx +411 -0
  143. package/docs-MachinaOs/nodes/overview.mdx +181 -0
  144. package/docs-MachinaOs/nodes/schedulers.mdx +316 -0
  145. package/docs-MachinaOs/nodes/webhooks.mdx +330 -0
  146. package/docs-MachinaOs/nodes/whatsapp.mdx +305 -0
  147. package/docs-MachinaOs/quickstart.mdx +119 -0
  148. package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +177 -0
  149. package/docs-MachinaOs/tutorials/android-automation.mdx +242 -0
  150. package/docs-MachinaOs/tutorials/first-workflow.mdx +134 -0
  151. package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +185 -0
  152. package/nul +0 -0
  153. package/package.json +70 -0
  154. package/scripts/build.js +158 -0
  155. package/scripts/check-ports.ps1 +33 -0
  156. package/scripts/clean.js +40 -0
  157. package/scripts/docker.js +93 -0
  158. package/scripts/kill-port.ps1 +154 -0
  159. package/scripts/start.js +210 -0
  160. package/scripts/stop.js +325 -0
  161. package/server/.dockerignore +44 -0
  162. package/server/Dockerfile +45 -0
  163. package/server/constants.py +249 -0
  164. package/server/core/__init__.py +1 -0
  165. package/server/core/cache.py +461 -0
  166. package/server/core/config.py +128 -0
  167. package/server/core/container.py +99 -0
  168. package/server/core/database.py +1211 -0
  169. package/server/core/logging.py +314 -0
  170. package/server/main.py +289 -0
  171. package/server/middleware/__init__.py +5 -0
  172. package/server/middleware/auth.py +89 -0
  173. package/server/models/__init__.py +1 -0
  174. package/server/models/auth.py +52 -0
  175. package/server/models/cache.py +24 -0
  176. package/server/models/database.py +211 -0
  177. package/server/models/nodes.py +455 -0
  178. package/server/package.json +9 -0
  179. package/server/pyproject.toml +72 -0
  180. package/server/requirements.txt +83 -0
  181. package/server/routers/__init__.py +1 -0
  182. package/server/routers/android.py +294 -0
  183. package/server/routers/auth.py +203 -0
  184. package/server/routers/database.py +151 -0
  185. package/server/routers/maps.py +142 -0
  186. package/server/routers/nodejs_compat.py +289 -0
  187. package/server/routers/webhook.py +90 -0
  188. package/server/routers/websocket.py +2127 -0
  189. package/server/routers/whatsapp.py +761 -0
  190. package/server/routers/workflow.py +200 -0
  191. package/server/services/__init__.py +1 -0
  192. package/server/services/ai.py +2415 -0
  193. package/server/services/android/__init__.py +27 -0
  194. package/server/services/android/broadcaster.py +114 -0
  195. package/server/services/android/client.py +608 -0
  196. package/server/services/android/manager.py +78 -0
  197. package/server/services/android/protocol.py +165 -0
  198. package/server/services/android_service.py +588 -0
  199. package/server/services/auth.py +131 -0
  200. package/server/services/chat_client.py +160 -0
  201. package/server/services/deployment/__init__.py +12 -0
  202. package/server/services/deployment/manager.py +706 -0
  203. package/server/services/deployment/state.py +47 -0
  204. package/server/services/deployment/triggers.py +275 -0
  205. package/server/services/event_waiter.py +785 -0
  206. package/server/services/execution/__init__.py +77 -0
  207. package/server/services/execution/cache.py +769 -0
  208. package/server/services/execution/conditions.py +373 -0
  209. package/server/services/execution/dlq.py +132 -0
  210. package/server/services/execution/executor.py +1351 -0
  211. package/server/services/execution/models.py +531 -0
  212. package/server/services/execution/recovery.py +235 -0
  213. package/server/services/handlers/__init__.py +126 -0
  214. package/server/services/handlers/ai.py +355 -0
  215. package/server/services/handlers/android.py +260 -0
  216. package/server/services/handlers/code.py +278 -0
  217. package/server/services/handlers/document.py +598 -0
  218. package/server/services/handlers/http.py +193 -0
  219. package/server/services/handlers/polyglot.py +105 -0
  220. package/server/services/handlers/tools.py +845 -0
  221. package/server/services/handlers/triggers.py +107 -0
  222. package/server/services/handlers/utility.py +822 -0
  223. package/server/services/handlers/whatsapp.py +476 -0
  224. package/server/services/maps.py +289 -0
  225. package/server/services/memory_store.py +103 -0
  226. package/server/services/node_executor.py +375 -0
  227. package/server/services/parameter_resolver.py +218 -0
  228. package/server/services/polyglot_client.py +169 -0
  229. package/server/services/scheduler.py +155 -0
  230. package/server/services/skill_loader.py +417 -0
  231. package/server/services/status_broadcaster.py +826 -0
  232. package/server/services/temporal/__init__.py +23 -0
  233. package/server/services/temporal/activities.py +344 -0
  234. package/server/services/temporal/client.py +76 -0
  235. package/server/services/temporal/executor.py +147 -0
  236. package/server/services/temporal/worker.py +251 -0
  237. package/server/services/temporal/workflow.py +355 -0
  238. package/server/services/temporal/ws_client.py +236 -0
  239. package/server/services/text.py +111 -0
  240. package/server/services/user_auth.py +172 -0
  241. package/server/services/websocket_client.py +29 -0
  242. package/server/services/workflow.py +597 -0
  243. package/server/skills/android-skill/SKILL.md +82 -0
  244. package/server/skills/assistant-personality/SKILL.md +45 -0
  245. package/server/skills/code-skill/SKILL.md +140 -0
  246. package/server/skills/http-skill/SKILL.md +161 -0
  247. package/server/skills/maps-skill/SKILL.md +170 -0
  248. package/server/skills/memory-skill/SKILL.md +154 -0
  249. package/server/skills/scheduler-skill/SKILL.md +84 -0
  250. package/server/skills/whatsapp-skill/SKILL.md +283 -0
  251. package/server/uv.lock +2916 -0
  252. package/server/whatsapp-rpc/.dockerignore +30 -0
  253. package/server/whatsapp-rpc/Dockerfile +44 -0
  254. package/server/whatsapp-rpc/Dockerfile.web +17 -0
  255. package/server/whatsapp-rpc/README.md +139 -0
  256. package/server/whatsapp-rpc/cli.js +95 -0
  257. package/server/whatsapp-rpc/configs/config.yaml +7 -0
  258. package/server/whatsapp-rpc/docker-compose.yml +35 -0
  259. package/server/whatsapp-rpc/docs/API.md +410 -0
  260. package/server/whatsapp-rpc/go.mod +67 -0
  261. package/server/whatsapp-rpc/go.sum +203 -0
  262. package/server/whatsapp-rpc/package.json +30 -0
  263. package/server/whatsapp-rpc/schema.json +1294 -0
  264. package/server/whatsapp-rpc/scripts/clean.cjs +66 -0
  265. package/server/whatsapp-rpc/scripts/cli.js +162 -0
  266. package/server/whatsapp-rpc/src/go/cmd/server/main.go +91 -0
  267. package/server/whatsapp-rpc/src/go/config/config.go +49 -0
  268. package/server/whatsapp-rpc/src/go/rpc/rpc.go +446 -0
  269. package/server/whatsapp-rpc/src/go/rpc/server.go +112 -0
  270. package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -0
  271. package/server/whatsapp-rpc/src/go/whatsapp/messages.go +390 -0
  272. package/server/whatsapp-rpc/src/go/whatsapp/service.go +2130 -0
  273. package/server/whatsapp-rpc/src/go/whatsapp/types.go +261 -0
  274. package/server/whatsapp-rpc/src/python/pyproject.toml +15 -0
  275. package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -0
  276. package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -0
  277. package/server/whatsapp-rpc/web/app.py +609 -0
  278. package/server/whatsapp-rpc/web/requirements.txt +6 -0
  279. package/server/whatsapp-rpc/web/rpc_client.py +427 -0
  280. package/server/whatsapp-rpc/web/static/openapi.yaml +59 -0
  281. package/server/whatsapp-rpc/web/templates/base.html +150 -0
  282. package/server/whatsapp-rpc/web/templates/contacts.html +240 -0
  283. package/server/whatsapp-rpc/web/templates/dashboard.html +320 -0
  284. package/server/whatsapp-rpc/web/templates/groups.html +328 -0
  285. package/server/whatsapp-rpc/web/templates/messages.html +465 -0
  286. package/server/whatsapp-rpc/web/templates/messaging.html +681 -0
  287. package/server/whatsapp-rpc/web/templates/send.html +259 -0
  288. package/server/whatsapp-rpc/web/templates/settings.html +459 -0
@@ -0,0 +1,608 @@
1
+ """
2
+ Android Services Relay WebSocket Client
3
+
4
+ Handles WebSocket connection to relay server and communication with paired Android device.
5
+
6
+ Connection flow:
7
+ 1. Connect to wss://<relay-server>/ws?client_type=web&api_key=<your-api-key>
8
+ 2. Receive connection.established with session_token and qr_data
9
+ 3. Display QR code for Android to scan
10
+ 4. Receive pairing.connected when Android pairs
11
+ 5. Exchange messages via relay.send / relay.message
12
+ """
13
+ import asyncio
14
+ import json
15
+ import uuid
16
+ import aiohttp
17
+ from typing import Optional, Dict, Any, Set, Callable
18
+ from datetime import datetime
19
+ import structlog
20
+
21
+ from .protocol import RPCRequest, RPCResponse, RPCEvent, RPCRequestTracker, parse_message, is_response
22
+ from .broadcaster import (
23
+ broadcast_android_status,
24
+ broadcast_connected,
25
+ broadcast_device_disconnected,
26
+ broadcast_relay_disconnected,
27
+ broadcast_qr_code
28
+ )
29
+
30
+ logger = structlog.get_logger()
31
+
32
+
33
+ class RelayWebSocketClient:
34
+ """WebSocket client for Android Services Relay using JSON-RPC 2.0 protocol"""
35
+
36
+ def __init__(self, base_url: str, api_key: str):
37
+ """
38
+ Initialize relay client.
39
+
40
+ Args:
41
+ base_url: Base WebSocket URL (e.g., 'wss://your-relay-server.com/ws')
42
+ api_key: API key for authentication
43
+ """
44
+ self.base_url = base_url
45
+ self.api_key = api_key
46
+ self.url = f"{base_url}?client_type=web&api_key={api_key}"
47
+
48
+ # WebSocket connection
49
+ self.ws: Optional[aiohttp.ClientWebSocketResponse] = None
50
+ self.session: Optional[aiohttp.ClientSession] = None
51
+ self.connected = False
52
+
53
+ # JSON-RPC request tracking
54
+ self._rpc_tracker = RPCRequestTracker()
55
+
56
+ # Pairing state
57
+ self.session_token: Optional[str] = None
58
+ self.qr_data: Optional[str] = None
59
+ self.paired = False
60
+ self.paired_device_id: Optional[str] = None
61
+ self.paired_device_name: Optional[str] = None
62
+
63
+ # Background tasks
64
+ self._receive_task: Optional[asyncio.Task] = None
65
+ self._keepalive_task: Optional[asyncio.Task] = None
66
+ self._running = False
67
+
68
+ # Service response queues (requestId -> queue)
69
+ self._service_queues: Dict[str, asyncio.Queue] = {}
70
+
71
+ # Event callbacks
72
+ self.on_pairing_connected: Optional[Callable] = None
73
+ self.on_pairing_disconnected: Optional[Callable] = None
74
+ self.on_relay_message: Optional[Callable] = None
75
+
76
+ # =========================================================================
77
+ # Connection Management
78
+ # =========================================================================
79
+
80
+ async def connect(self) -> tuple[bool, str]:
81
+ """Connect to relay WebSocket server.
82
+
83
+ Returns:
84
+ Tuple of (success: bool, error_message: str)
85
+ """
86
+ try:
87
+ logger.info("[Relay] Connecting...", url=self.url)
88
+ timeout = aiohttp.ClientTimeout(total=None, connect=10, sock_read=300)
89
+ self.session = aiohttp.ClientSession(timeout=timeout)
90
+
91
+ self.ws = await self.session.ws_connect(
92
+ self.url,
93
+ heartbeat=30,
94
+ autoping=True,
95
+ ssl=True # Explicit SSL for wss://
96
+ )
97
+ self.connected = True
98
+ self._running = True
99
+
100
+ logger.info("[Relay] WebSocket connected, waiting for server message...", url=self.base_url)
101
+
102
+ # Wait for connection.established event
103
+ msg = await asyncio.wait_for(self.ws.receive(), timeout=10.0)
104
+
105
+ if msg.type == aiohttp.WSMsgType.TEXT:
106
+ data = json.loads(msg.data)
107
+ method = data.get("method")
108
+ logger.info("[Relay] Received initial message", method=method)
109
+
110
+ # Handle both "welcome" and "connection.established" methods
111
+ if method in ("welcome", "connection.established"):
112
+ params = data.get("params", {})
113
+ self.session_token = params.get("session_token")
114
+ self.qr_data = params.get("qr_data")
115
+
116
+ logger.info("[Relay] Connection established",
117
+ session_token=self.session_token,
118
+ has_qr=bool(self.qr_data))
119
+
120
+ # Broadcast QR data to frontend
121
+ if self.qr_data:
122
+ await broadcast_qr_code(self.qr_data)
123
+
124
+ # Start background tasks
125
+ self._receive_task = asyncio.create_task(self._receive_loop())
126
+ self._keepalive_task = asyncio.create_task(self._keepalive_loop())
127
+
128
+ return True, ""
129
+ elif data.get("error"):
130
+ error_msg = data.get("error", {}).get("message", "Unknown server error")
131
+ logger.error("[Relay] Server error", error=error_msg)
132
+ return False, f"Server error: {error_msg}"
133
+ else:
134
+ logger.error("[Relay] Unexpected initial message", data=data)
135
+ return False, f"Unexpected response: {method or 'unknown'}"
136
+
137
+ elif msg.type == aiohttp.WSMsgType.CLOSE:
138
+ close_code = msg.data
139
+ close_reason = msg.extra or "Unknown"
140
+ logger.error("[Relay] Connection closed by server", code=close_code, reason=close_reason)
141
+ return False, f"Connection closed: {close_reason} (code {close_code})"
142
+
143
+ elif msg.type == aiohttp.WSMsgType.ERROR:
144
+ logger.error("[Relay] WebSocket error on receive")
145
+ return False, "WebSocket error during handshake"
146
+
147
+ return False, "No response from server"
148
+
149
+ except asyncio.TimeoutError:
150
+ logger.error("[Relay] Connection timeout")
151
+ return False, "Connection timeout - server not responding"
152
+ except aiohttp.ClientConnectorError as e:
153
+ logger.error("[Relay] Connection failed", error=str(e))
154
+ return False, f"Cannot connect to server: {str(e)}"
155
+ except aiohttp.WSServerHandshakeError as e:
156
+ logger.error("[Relay] WebSocket handshake failed", error=str(e))
157
+ return False, f"WebSocket handshake failed: {str(e)}"
158
+ except Exception as e:
159
+ logger.error("[Relay] Connection error", error=str(e), exc_info=True)
160
+ return False, f"Connection error: {str(e)}"
161
+
162
+ async def disconnect(self, clear_stored_session: bool = True):
163
+ """Close connection and cleanup.
164
+
165
+ Args:
166
+ clear_stored_session: If True, clear stored pairing session from database.
167
+ Set to False when disconnecting due to connection drop
168
+ (will try to auto-reconnect later).
169
+ """
170
+ logger.info("[Relay] Disconnecting...", clear_stored_session=clear_stored_session)
171
+ self._running = False
172
+
173
+ # Cancel background tasks
174
+ for task in [self._keepalive_task, self._receive_task]:
175
+ if task and not task.done():
176
+ task.cancel()
177
+ try:
178
+ await task
179
+ except asyncio.CancelledError:
180
+ pass
181
+
182
+ # Close WebSocket
183
+ if self.ws and not self.ws.closed:
184
+ await self.ws.close()
185
+
186
+ if self.session and not self.session.closed:
187
+ await self.session.close()
188
+
189
+ # Reset state
190
+ self.connected = False
191
+ self.paired = False
192
+ self.paired_device_id = None
193
+ self.paired_device_name = None
194
+ self.session_token = None
195
+ self.qr_data = None
196
+
197
+ # Cancel pending RPC requests
198
+ self._rpc_tracker.cancel_all()
199
+
200
+ # Clear stored session if explicitly disconnecting
201
+ if clear_stored_session:
202
+ await self._clear_stored_session()
203
+
204
+ # Broadcast relay disconnection (fully disconnected from relay server)
205
+ await broadcast_relay_disconnected()
206
+
207
+ logger.info("[Relay] Disconnected")
208
+
209
+ async def _clear_stored_session(self):
210
+ """Clear stored pairing session from database."""
211
+ try:
212
+ from core.container import container
213
+ database = container.database()
214
+
215
+ await database.clear_android_relay_session()
216
+ logger.info("[Relay] Cleared stored pairing session")
217
+ except Exception as e:
218
+ logger.warning("[Relay] Failed to clear stored session", error=str(e))
219
+
220
+ def is_connected(self) -> bool:
221
+ """Check if connected to relay server."""
222
+ return self.connected and self._running and self.ws is not None and not self.ws.closed
223
+
224
+ def is_paired(self) -> bool:
225
+ """Check if paired with Android device."""
226
+ return self.paired and self.paired_device_id is not None
227
+
228
+ # =========================================================================
229
+ # Background Tasks
230
+ # =========================================================================
231
+
232
+ async def _receive_loop(self):
233
+ """Background task to receive messages."""
234
+ logger.info("[Relay] Receive loop started")
235
+ unexpected_disconnect = False
236
+ try:
237
+ while self._running and self.ws and not self.ws.closed:
238
+ try:
239
+ msg = await self.ws.receive()
240
+
241
+ if msg.type == aiohttp.WSMsgType.TEXT:
242
+ data = json.loads(msg.data)
243
+ await self._handle_message(data)
244
+
245
+ elif msg.type == aiohttp.WSMsgType.CLOSED:
246
+ logger.warning("[Relay] Connection closed by server")
247
+ self._running = False
248
+ unexpected_disconnect = True
249
+ break
250
+
251
+ elif msg.type == aiohttp.WSMsgType.ERROR:
252
+ logger.error("[Relay] WebSocket error")
253
+ self._running = False
254
+ unexpected_disconnect = True
255
+ break
256
+
257
+ except Exception as e:
258
+ logger.error("[Relay] Receive error", error=str(e))
259
+ await asyncio.sleep(1)
260
+
261
+ except asyncio.CancelledError:
262
+ pass
263
+ finally:
264
+ self._running = False
265
+ self.connected = False
266
+ logger.info("[Relay] Receive loop stopped")
267
+
268
+ # Broadcast relay disconnection if connection dropped unexpectedly
269
+ if unexpected_disconnect:
270
+ try:
271
+ await broadcast_relay_disconnected()
272
+ except Exception as e:
273
+ logger.warning("[Relay] Failed to broadcast disconnection", error=str(e))
274
+
275
+ async def _keepalive_loop(self):
276
+ """Background keepalive task."""
277
+ try:
278
+ while self._running and self.ws and not self.ws.closed:
279
+ await asyncio.sleep(25)
280
+ if self._running and self.ws and not self.ws.closed:
281
+ try:
282
+ await self.ws.send_json({
283
+ "jsonrpc": "2.0",
284
+ "method": "ping",
285
+ "params": {}
286
+ })
287
+ except Exception as e:
288
+ logger.error("[Relay] Keepalive error", error=str(e))
289
+ self._running = False
290
+ break
291
+ except asyncio.CancelledError:
292
+ pass
293
+
294
+ # =========================================================================
295
+ # Message Handling
296
+ # =========================================================================
297
+
298
+ async def _handle_message(self, data: dict):
299
+ """Handle incoming JSON-RPC message."""
300
+ # Log ALL incoming messages for debugging
301
+ method = data.get("method", "")
302
+ logger.info("[Relay] Received message", method=method, has_result="result" in data, has_error="error" in data)
303
+
304
+ # Check if response to pending request
305
+ if is_response(data):
306
+ response = RPCResponse.from_dict(data)
307
+ logger.debug("[Relay] Processing as RPC response", id=response.id)
308
+ if self._rpc_tracker.resolve(response):
309
+ return
310
+
311
+ # Handle server events
312
+ params = data.get("params", {})
313
+
314
+ if method == "pairing.connected":
315
+ await self._handle_pairing_connected(params)
316
+
317
+ elif method == "pairing.restored":
318
+ # Handle auto-reconnect of previously paired device
319
+ await self._handle_pairing_restored(params)
320
+
321
+ elif method == "pairing.disconnected":
322
+ await self._handle_pairing_disconnected(params)
323
+
324
+ elif method == "relay.message":
325
+ await self._handle_relay_message(params)
326
+
327
+ elif method == "connection.established":
328
+ # Reconnect scenario
329
+ self.session_token = params.get("session_token")
330
+ self.qr_data = params.get("qr_data")
331
+ if self.qr_data:
332
+ await broadcast_qr_code(self.qr_data)
333
+
334
+ async def _handle_pairing_connected(self, params: dict):
335
+ """Handle pairing.connected event."""
336
+ self.paired = True
337
+ self.paired_device_id = params.get("device_id")
338
+ self.paired_device_name = params.get("device_name")
339
+
340
+ logger.info("[Relay] Android paired",
341
+ device_id=self.paired_device_id,
342
+ device_name=self.paired_device_name)
343
+
344
+ await broadcast_connected(self.paired_device_id, self.paired_device_name)
345
+
346
+ # Persist pairing data for auto-reconnect on server restart
347
+ await self._save_pairing_session()
348
+
349
+ if self.on_pairing_connected:
350
+ await self.on_pairing_connected(params)
351
+
352
+ async def _handle_pairing_restored(self, params: dict):
353
+ """Handle pairing.restored event - auto-reconnect of previously paired device."""
354
+ self.paired = True
355
+ self.paired_device_id = params.get("device_id")
356
+ self.paired_device_name = params.get("device_name")
357
+
358
+ logger.info("[Relay] Android pairing restored (auto-reconnect)",
359
+ device_id=self.paired_device_id,
360
+ device_name=self.paired_device_name)
361
+
362
+ await broadcast_connected(self.paired_device_id, self.paired_device_name)
363
+
364
+ # Update saved session with latest info
365
+ await self._save_pairing_session()
366
+
367
+ if self.on_pairing_connected:
368
+ await self.on_pairing_connected(params)
369
+
370
+ async def _save_pairing_session(self):
371
+ """Save pairing session to database for auto-reconnect."""
372
+ try:
373
+ from core.container import container
374
+ database = container.database()
375
+
376
+ await database.save_android_relay_session(
377
+ relay_url=self.base_url,
378
+ api_key=self.api_key,
379
+ device_id=self.paired_device_id,
380
+ device_name=self.paired_device_name,
381
+ session_token=self.session_token
382
+ )
383
+ logger.info("[Relay] Pairing session saved for auto-reconnect")
384
+ except Exception as e:
385
+ logger.warning("[Relay] Failed to save pairing session", error=str(e))
386
+
387
+ async def _handle_pairing_disconnected(self, params: dict):
388
+ """Handle pairing.disconnected event.
389
+
390
+ The Android device has disconnected, but the relay connection may still be active.
391
+ This allows the user to re-scan the QR code without reconnecting to the relay.
392
+ """
393
+ reason = params.get("reason", "unknown")
394
+ logger.info("[Relay] Android device disconnected", reason=reason)
395
+
396
+ self.paired = False
397
+ self.paired_device_id = None
398
+ self.paired_device_name = None
399
+
400
+ # Broadcast device disconnection - relay is still connected, pass QR data for re-pairing
401
+ await broadcast_device_disconnected(
402
+ relay_connected=self.is_connected(),
403
+ qr_data=self.qr_data,
404
+ session_token=self.session_token
405
+ )
406
+
407
+ if self.on_pairing_disconnected:
408
+ await self.on_pairing_disconnected(params)
409
+
410
+ async def _handle_relay_message(self, params: dict):
411
+ """Handle relay.message event from Android.
412
+
413
+ Schema: relay.message params = {"data": {...}}
414
+ The data contains the actual message from Android.
415
+ """
416
+ # Schema: params = {"data": {...}}
417
+ data = params.get("data", {})
418
+
419
+ logger.info("[Relay] relay.message received",
420
+ data_keys=list(data.keys()) if isinstance(data, dict) else "not_dict",
421
+ data=data)
422
+
423
+ # Route to service response queue if matching request_id
424
+ # Android app uses "request_id" (underscore), not "requestId" (camelCase)
425
+ request_id = data.get("request_id")
426
+ logger.info("[Relay] Checking request_id", request_id=request_id, waiting_for=list(self._service_queues.keys()))
427
+
428
+ if request_id and request_id in self._service_queues:
429
+ logger.info("[Relay] Routing to service queue", request_id=request_id)
430
+ await self._service_queues[request_id].put(data)
431
+ elif self.on_relay_message:
432
+ logger.info("[Relay] Passing to on_relay_message callback")
433
+ await self.on_relay_message(data)
434
+ else:
435
+ logger.warning("[Relay] Unhandled relay message", request_id=request_id, data=data)
436
+
437
+ # =========================================================================
438
+ # RPC Methods
439
+ # =========================================================================
440
+
441
+ async def call(self, method: str, params: Dict[str, Any] = None, timeout: float = 30) -> Any:
442
+ """
443
+ Make JSON-RPC 2.0 call and wait for response.
444
+
445
+ Args:
446
+ method: RPC method name
447
+ params: Method parameters
448
+ timeout: Response timeout in seconds
449
+
450
+ Returns:
451
+ Result from the RPC call
452
+ """
453
+ if not self.is_connected():
454
+ raise Exception("Not connected to relay server")
455
+
456
+ request, future = self._rpc_tracker.create_request(method, params)
457
+
458
+ try:
459
+ req_dict = request.to_dict()
460
+ logger.debug("[Relay] Sending RPC request", method=method, id=request.id)
461
+ await self.ws.send_json(req_dict)
462
+ result = await asyncio.wait_for(future, timeout)
463
+ logger.debug("[Relay] RPC response received", method=method, id=request.id)
464
+ return result
465
+ except asyncio.TimeoutError:
466
+ self._rpc_tracker.cancel(request.id)
467
+ raise Exception(f"RPC call '{method}' timed out after {timeout}s")
468
+
469
+ async def get_pairing_status(self) -> Dict[str, Any]:
470
+ """Get current pairing status."""
471
+ return await self.call("pairing.status")
472
+
473
+ async def disconnect_pairing(self) -> Dict[str, Any]:
474
+ """End pairing session."""
475
+ result = await self.call("pairing.disconnect")
476
+ self.paired = False
477
+ self.paired_device_id = None
478
+ self.paired_device_name = None
479
+ return result
480
+
481
+ async def relay_send(self, data: Dict[str, Any], timeout: float = 30) -> Dict[str, Any]:
482
+ """
483
+ Send message to paired Android device via relay.
484
+
485
+ Schema: relay.send params = {"data": {...}}
486
+
487
+ Args:
488
+ data: Message data to send to Android device
489
+ timeout: Response timeout
490
+ """
491
+ if not self.paired:
492
+ raise Exception("Not paired with Android device")
493
+
494
+ logger.info("[Relay] Sending relay.send RPC", data=data)
495
+
496
+ # Schema: {"jsonrpc": "2.0", "method": "relay.send", "params": {"data": {...}}, "id": 1}
497
+ result = await self.call("relay.send", {"data": data}, timeout=timeout)
498
+ logger.info("[Relay] relay.send RPC response", result=result)
499
+ return result
500
+
501
+ # =========================================================================
502
+ # Service Requests
503
+ # =========================================================================
504
+
505
+ async def send_service_request(
506
+ self,
507
+ service_id: str,
508
+ action: str,
509
+ parameters: Dict[str, Any] = None,
510
+ target_id: Optional[str] = None, # Ignored, kept for compatibility
511
+ timeout: float = 30.0
512
+ ) -> Optional[Dict[str, Any]]:
513
+ """
514
+ Send service request to paired Android device.
515
+
516
+ Args:
517
+ service_id: Android service ID (e.g., 'battery', 'wifi_automation')
518
+ action: Service action (e.g., 'status', 'enable')
519
+ parameters: Action parameters
520
+ target_id: Ignored (kept for API compatibility)
521
+ timeout: Response timeout in seconds
522
+
523
+ Returns:
524
+ Response data or None if timeout/error
525
+ """
526
+ if not self.paired:
527
+ logger.error("[Relay] Cannot send - not paired")
528
+ return None
529
+
530
+ request_id = str(uuid.uuid4())
531
+ response_queue: asyncio.Queue = asyncio.Queue()
532
+ self._service_queues[request_id] = response_queue
533
+
534
+ try:
535
+ # Send via relay - schema: relay.send params = {"data": {...}}
536
+ # Field names must match Android app expectations:
537
+ # - service (not serviceId)
538
+ # - action
539
+ # - request_id (not requestId)
540
+ # - params (not parameters)
541
+ await self.relay_send({
542
+ "service": service_id,
543
+ "action": action,
544
+ "request_id": request_id,
545
+ "params": parameters or {}
546
+ }, timeout=5.0)
547
+
548
+ logger.info("[Relay] Sent service request",
549
+ request_id=request_id,
550
+ service_id=service_id,
551
+ action=action)
552
+
553
+ # Wait for response
554
+ logger.info("[Relay] Waiting for response", request_id=request_id, timeout=timeout)
555
+ response = await asyncio.wait_for(response_queue.get(), timeout=timeout)
556
+ logger.info("[Relay] Service response received", request_id=request_id, response_keys=list(response.keys()) if isinstance(response, dict) else "not_dict")
557
+ return response
558
+
559
+ except asyncio.TimeoutError:
560
+ logger.warning("[Relay] Service response timeout", request_id=request_id, timeout=timeout, pending_queues=list(self._service_queues.keys()))
561
+ return None
562
+ except Exception as e:
563
+ logger.error("[Relay] Service request error", error=str(e))
564
+ return None
565
+ finally:
566
+ self._service_queues.pop(request_id, None)
567
+
568
+ async def wait_for_pairing(self, timeout: float = 60.0) -> bool:
569
+ """
570
+ Wait for Android device to pair.
571
+
572
+ Args:
573
+ timeout: Maximum time to wait in seconds
574
+
575
+ Returns:
576
+ True if paired successfully, False if timeout
577
+ """
578
+ if self.paired:
579
+ return True
580
+
581
+ logger.info("[Relay] Waiting for pairing...", timeout=timeout)
582
+
583
+ start = asyncio.get_event_loop().time()
584
+ while asyncio.get_event_loop().time() - start < timeout:
585
+ if self.paired:
586
+ return True
587
+ await asyncio.sleep(0.5)
588
+
589
+ logger.warning("[Relay] Pairing timeout")
590
+ return False
591
+
592
+ # =========================================================================
593
+ # Legacy Compatibility
594
+ # =========================================================================
595
+
596
+ def get_android_device_id(self) -> Optional[str]:
597
+ """Get paired Android device ID."""
598
+ return self.paired_device_id
599
+
600
+ def has_real_android_devices(self) -> bool:
601
+ """Check if paired with Android device."""
602
+ return self.paired
603
+
604
+ def get_connected_devices(self) -> Set[str]:
605
+ """Get set of connected Android device IDs."""
606
+ if self.paired_device_id:
607
+ return {self.paired_device_id}
608
+ return set()
@@ -0,0 +1,78 @@
1
+ """
2
+ Android Relay Client Manager
3
+
4
+ Global instance management for persistent WebSocket connection.
5
+ Provides singleton pattern for reusing connection across API requests.
6
+ """
7
+ from typing import Optional
8
+ import structlog
9
+
10
+ from .client import RelayWebSocketClient
11
+
12
+ logger = structlog.get_logger()
13
+
14
+ # Global client instance
15
+ _relay_client: Optional[RelayWebSocketClient] = None
16
+
17
+
18
+ async def get_relay_client(base_url: str, api_key: str) -> tuple[Optional[RelayWebSocketClient], str]:
19
+ """
20
+ Get or create persistent relay client instance.
21
+
22
+ Args:
23
+ base_url: WebSocket URL (e.g., 'wss://your-relay-server.com/ws')
24
+ api_key: API key for authentication
25
+
26
+ Returns:
27
+ Tuple of (client or None, error_message)
28
+ """
29
+ global _relay_client
30
+
31
+ # Reuse existing connection if valid
32
+ if _relay_client and _relay_client.is_connected():
33
+ logger.info("[Manager] Reusing existing connection")
34
+ return _relay_client, ""
35
+
36
+ # Close stale connection
37
+ if _relay_client:
38
+ await _relay_client.disconnect()
39
+
40
+ # Create new connection
41
+ logger.info("[Manager] Creating new connection", url=base_url)
42
+ _relay_client = RelayWebSocketClient(base_url, api_key)
43
+ connected, error = await _relay_client.connect()
44
+
45
+ if connected:
46
+ logger.info("[Manager] Connection established")
47
+ return _relay_client, ""
48
+ else:
49
+ _relay_client = None
50
+ logger.error("[Manager] Failed to connect", error=error)
51
+ return None, error
52
+
53
+
54
+ async def close_relay_client(clear_stored_session: bool = True):
55
+ """Close global relay client.
56
+
57
+ Args:
58
+ clear_stored_session: If True, clear stored pairing session from database.
59
+ This prevents auto-reconnect on next client connect.
60
+ """
61
+ global _relay_client
62
+ if _relay_client:
63
+ logger.info("[Manager] Closing connection", clear_stored_session=clear_stored_session)
64
+ await _relay_client.disconnect(clear_stored_session=clear_stored_session)
65
+ _relay_client = None
66
+
67
+
68
+ def get_current_relay_client() -> Optional[RelayWebSocketClient]:
69
+ """
70
+ Get current relay client if connected.
71
+
72
+ Returns:
73
+ Connected client or None
74
+ """
75
+ global _relay_client
76
+ if _relay_client and _relay_client.is_connected():
77
+ return _relay_client
78
+ return None