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.
- package/.env.template +71 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/cli.js +159 -0
- package/client/.dockerignore +45 -0
- package/client/Dockerfile +68 -0
- package/client/eslint.config.js +29 -0
- package/client/index.html +13 -0
- package/client/nginx.conf +66 -0
- package/client/package.json +48 -0
- package/client/src/App.tsx +27 -0
- package/client/src/Dashboard.tsx +1173 -0
- package/client/src/ParameterPanel.tsx +301 -0
- package/client/src/components/AIAgentNode.tsx +321 -0
- package/client/src/components/APIKeyValidator.tsx +118 -0
- package/client/src/components/ClaudeChatModelNode.tsx +18 -0
- package/client/src/components/ConditionalEdge.tsx +189 -0
- package/client/src/components/CredentialsModal.tsx +306 -0
- package/client/src/components/EdgeConditionEditor.tsx +443 -0
- package/client/src/components/GeminiChatModelNode.tsx +18 -0
- package/client/src/components/GenericNode.tsx +357 -0
- package/client/src/components/LocationParameterPanel.tsx +154 -0
- package/client/src/components/ModelNode.tsx +286 -0
- package/client/src/components/OpenAIChatModelNode.tsx +18 -0
- package/client/src/components/OutputPanel.tsx +471 -0
- package/client/src/components/ParameterRenderer.tsx +1874 -0
- package/client/src/components/SkillEditorModal.tsx +417 -0
- package/client/src/components/SquareNode.tsx +797 -0
- package/client/src/components/StartNode.tsx +250 -0
- package/client/src/components/ToolkitNode.tsx +365 -0
- package/client/src/components/TriggerNode.tsx +463 -0
- package/client/src/components/auth/LoginPage.tsx +247 -0
- package/client/src/components/auth/ProtectedRoute.tsx +59 -0
- package/client/src/components/base/BaseChatModelNode.tsx +271 -0
- package/client/src/components/icons/AIProviderIcons.tsx +50 -0
- package/client/src/components/maps/GoogleMapsPicker.tsx +137 -0
- package/client/src/components/maps/MapsPreviewPanel.tsx +110 -0
- package/client/src/components/maps/index.ts +26 -0
- package/client/src/components/parameterPanel/InputSection.tsx +1094 -0
- package/client/src/components/parameterPanel/LocationPanelLayout.tsx +65 -0
- package/client/src/components/parameterPanel/MapsSection.tsx +92 -0
- package/client/src/components/parameterPanel/MiddleSection.tsx +571 -0
- package/client/src/components/parameterPanel/OutputSection.tsx +81 -0
- package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +82 -0
- package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -0
- package/client/src/components/parameterPanel/index.ts +42 -0
- package/client/src/components/shared/DataPanel.tsx +142 -0
- package/client/src/components/shared/JSONTreeRenderer.tsx +106 -0
- package/client/src/components/ui/AIResultModal.tsx +204 -0
- package/client/src/components/ui/AndroidSettingsPanel.tsx +401 -0
- package/client/src/components/ui/CodeEditor.tsx +81 -0
- package/client/src/components/ui/CollapsibleSection.tsx +88 -0
- package/client/src/components/ui/ComponentItem.tsx +154 -0
- package/client/src/components/ui/ComponentPalette.tsx +321 -0
- package/client/src/components/ui/ConsolePanel.tsx +1074 -0
- package/client/src/components/ui/ErrorBoundary.tsx +196 -0
- package/client/src/components/ui/InputNodesPanel.tsx +204 -0
- package/client/src/components/ui/MapSelector.tsx +314 -0
- package/client/src/components/ui/Modal.tsx +149 -0
- package/client/src/components/ui/NodeContextMenu.tsx +192 -0
- package/client/src/components/ui/NodeOutputPanel.tsx +1150 -0
- package/client/src/components/ui/OutputDisplayPanel.tsx +381 -0
- package/client/src/components/ui/SettingsPanel.tsx +243 -0
- package/client/src/components/ui/TopToolbar.tsx +736 -0
- package/client/src/components/ui/WhatsAppSettingsPanel.tsx +345 -0
- package/client/src/components/ui/WorkflowSidebar.tsx +294 -0
- package/client/src/config/antdTheme.ts +186 -0
- package/client/src/config/api.ts +54 -0
- package/client/src/contexts/AuthContext.tsx +221 -0
- package/client/src/contexts/ThemeContext.tsx +42 -0
- package/client/src/contexts/WebSocketContext.tsx +1971 -0
- package/client/src/factories/baseChatModelFactory.ts +256 -0
- package/client/src/hooks/useAndroidOperations.ts +164 -0
- package/client/src/hooks/useApiKeyValidation.ts +107 -0
- package/client/src/hooks/useApiKeys.ts +238 -0
- package/client/src/hooks/useAppTheme.ts +17 -0
- package/client/src/hooks/useComponentPalette.ts +51 -0
- package/client/src/hooks/useCopyPaste.ts +155 -0
- package/client/src/hooks/useDragAndDrop.ts +124 -0
- package/client/src/hooks/useDragVariable.ts +88 -0
- package/client/src/hooks/useExecution.ts +313 -0
- package/client/src/hooks/useParameterPanel.ts +176 -0
- package/client/src/hooks/useReactFlowNodes.ts +189 -0
- package/client/src/hooks/useToolSchema.ts +209 -0
- package/client/src/hooks/useWhatsApp.ts +196 -0
- package/client/src/hooks/useWorkflowManagement.ts +46 -0
- package/client/src/index.css +315 -0
- package/client/src/main.tsx +19 -0
- package/client/src/nodeDefinitions/aiAgentNodes.ts +336 -0
- package/client/src/nodeDefinitions/aiModelNodes.ts +340 -0
- package/client/src/nodeDefinitions/androidDeviceNodes.ts +140 -0
- package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -0
- package/client/src/nodeDefinitions/chatNodes.ts +135 -0
- package/client/src/nodeDefinitions/codeNodes.ts +54 -0
- package/client/src/nodeDefinitions/documentNodes.ts +379 -0
- package/client/src/nodeDefinitions/index.ts +15 -0
- package/client/src/nodeDefinitions/locationNodes.ts +463 -0
- package/client/src/nodeDefinitions/schedulerNodes.ts +220 -0
- package/client/src/nodeDefinitions/skillNodes.ts +211 -0
- package/client/src/nodeDefinitions/toolNodes.ts +198 -0
- package/client/src/nodeDefinitions/utilityNodes.ts +284 -0
- package/client/src/nodeDefinitions/whatsappNodes.ts +865 -0
- package/client/src/nodeDefinitions/workflowNodes.ts +41 -0
- package/client/src/nodeDefinitions.ts +104 -0
- package/client/src/schemas/workflowSchema.ts +264 -0
- package/client/src/services/dynamicParameterService.ts +96 -0
- package/client/src/services/execution/aiAgentExecutionService.ts +35 -0
- package/client/src/services/executionService.ts +232 -0
- package/client/src/services/workflowApi.ts +91 -0
- package/client/src/store/useAppStore.ts +582 -0
- package/client/src/styles/theme.ts +508 -0
- package/client/src/styles/zIndex.ts +17 -0
- package/client/src/types/ComponentTypes.ts +39 -0
- package/client/src/types/EdgeCondition.ts +231 -0
- package/client/src/types/INodeProperties.ts +288 -0
- package/client/src/types/NodeTypes.ts +28 -0
- package/client/src/utils/formatters.ts +33 -0
- package/client/src/utils/googleMapsLoader.ts +140 -0
- package/client/src/utils/locationUtils.ts +85 -0
- package/client/src/utils/nodeUtils.ts +31 -0
- package/client/src/utils/workflow.ts +30 -0
- package/client/src/utils/workflowExport.ts +120 -0
- package/client/src/vite-env.d.ts +12 -0
- package/client/tailwind.config.js +60 -0
- package/client/tsconfig.json +25 -0
- package/client/tsconfig.node.json +11 -0
- package/client/vite.config.js +35 -0
- package/docker-compose.prod.yml +107 -0
- package/docker-compose.yml +104 -0
- package/docs-MachinaOs/README.md +85 -0
- package/docs-MachinaOs/deployment/docker.mdx +228 -0
- package/docs-MachinaOs/deployment/production.mdx +345 -0
- package/docs-MachinaOs/docs.json +75 -0
- package/docs-MachinaOs/faq.mdx +309 -0
- package/docs-MachinaOs/favicon.svg +5 -0
- package/docs-MachinaOs/installation.mdx +160 -0
- package/docs-MachinaOs/introduction.mdx +114 -0
- package/docs-MachinaOs/logo/dark.svg +6 -0
- package/docs-MachinaOs/logo/light.svg +6 -0
- package/docs-MachinaOs/nodes/ai-agent.mdx +216 -0
- package/docs-MachinaOs/nodes/ai-models.mdx +240 -0
- package/docs-MachinaOs/nodes/android.mdx +411 -0
- package/docs-MachinaOs/nodes/overview.mdx +181 -0
- package/docs-MachinaOs/nodes/schedulers.mdx +316 -0
- package/docs-MachinaOs/nodes/webhooks.mdx +330 -0
- package/docs-MachinaOs/nodes/whatsapp.mdx +305 -0
- package/docs-MachinaOs/quickstart.mdx +119 -0
- package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +177 -0
- package/docs-MachinaOs/tutorials/android-automation.mdx +242 -0
- package/docs-MachinaOs/tutorials/first-workflow.mdx +134 -0
- package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +185 -0
- package/nul +0 -0
- package/package.json +70 -0
- package/scripts/build.js +158 -0
- package/scripts/check-ports.ps1 +33 -0
- package/scripts/clean.js +40 -0
- package/scripts/docker.js +93 -0
- package/scripts/kill-port.ps1 +154 -0
- package/scripts/start.js +210 -0
- package/scripts/stop.js +325 -0
- package/server/.dockerignore +44 -0
- package/server/Dockerfile +45 -0
- package/server/constants.py +249 -0
- package/server/core/__init__.py +1 -0
- package/server/core/cache.py +461 -0
- package/server/core/config.py +128 -0
- package/server/core/container.py +99 -0
- package/server/core/database.py +1211 -0
- package/server/core/logging.py +314 -0
- package/server/main.py +289 -0
- package/server/middleware/__init__.py +5 -0
- package/server/middleware/auth.py +89 -0
- package/server/models/__init__.py +1 -0
- package/server/models/auth.py +52 -0
- package/server/models/cache.py +24 -0
- package/server/models/database.py +211 -0
- package/server/models/nodes.py +455 -0
- package/server/package.json +9 -0
- package/server/pyproject.toml +72 -0
- package/server/requirements.txt +83 -0
- package/server/routers/__init__.py +1 -0
- package/server/routers/android.py +294 -0
- package/server/routers/auth.py +203 -0
- package/server/routers/database.py +151 -0
- package/server/routers/maps.py +142 -0
- package/server/routers/nodejs_compat.py +289 -0
- package/server/routers/webhook.py +90 -0
- package/server/routers/websocket.py +2127 -0
- package/server/routers/whatsapp.py +761 -0
- package/server/routers/workflow.py +200 -0
- package/server/services/__init__.py +1 -0
- package/server/services/ai.py +2415 -0
- package/server/services/android/__init__.py +27 -0
- package/server/services/android/broadcaster.py +114 -0
- package/server/services/android/client.py +608 -0
- package/server/services/android/manager.py +78 -0
- package/server/services/android/protocol.py +165 -0
- package/server/services/android_service.py +588 -0
- package/server/services/auth.py +131 -0
- package/server/services/chat_client.py +160 -0
- package/server/services/deployment/__init__.py +12 -0
- package/server/services/deployment/manager.py +706 -0
- package/server/services/deployment/state.py +47 -0
- package/server/services/deployment/triggers.py +275 -0
- package/server/services/event_waiter.py +785 -0
- package/server/services/execution/__init__.py +77 -0
- package/server/services/execution/cache.py +769 -0
- package/server/services/execution/conditions.py +373 -0
- package/server/services/execution/dlq.py +132 -0
- package/server/services/execution/executor.py +1351 -0
- package/server/services/execution/models.py +531 -0
- package/server/services/execution/recovery.py +235 -0
- package/server/services/handlers/__init__.py +126 -0
- package/server/services/handlers/ai.py +355 -0
- package/server/services/handlers/android.py +260 -0
- package/server/services/handlers/code.py +278 -0
- package/server/services/handlers/document.py +598 -0
- package/server/services/handlers/http.py +193 -0
- package/server/services/handlers/polyglot.py +105 -0
- package/server/services/handlers/tools.py +845 -0
- package/server/services/handlers/triggers.py +107 -0
- package/server/services/handlers/utility.py +822 -0
- package/server/services/handlers/whatsapp.py +476 -0
- package/server/services/maps.py +289 -0
- package/server/services/memory_store.py +103 -0
- package/server/services/node_executor.py +375 -0
- package/server/services/parameter_resolver.py +218 -0
- package/server/services/polyglot_client.py +169 -0
- package/server/services/scheduler.py +155 -0
- package/server/services/skill_loader.py +417 -0
- package/server/services/status_broadcaster.py +826 -0
- package/server/services/temporal/__init__.py +23 -0
- package/server/services/temporal/activities.py +344 -0
- package/server/services/temporal/client.py +76 -0
- package/server/services/temporal/executor.py +147 -0
- package/server/services/temporal/worker.py +251 -0
- package/server/services/temporal/workflow.py +355 -0
- package/server/services/temporal/ws_client.py +236 -0
- package/server/services/text.py +111 -0
- package/server/services/user_auth.py +172 -0
- package/server/services/websocket_client.py +29 -0
- package/server/services/workflow.py +597 -0
- package/server/skills/android-skill/SKILL.md +82 -0
- package/server/skills/assistant-personality/SKILL.md +45 -0
- package/server/skills/code-skill/SKILL.md +140 -0
- package/server/skills/http-skill/SKILL.md +161 -0
- package/server/skills/maps-skill/SKILL.md +170 -0
- package/server/skills/memory-skill/SKILL.md +154 -0
- package/server/skills/scheduler-skill/SKILL.md +84 -0
- package/server/skills/whatsapp-skill/SKILL.md +283 -0
- package/server/uv.lock +2916 -0
- package/server/whatsapp-rpc/.dockerignore +30 -0
- package/server/whatsapp-rpc/Dockerfile +44 -0
- package/server/whatsapp-rpc/Dockerfile.web +17 -0
- package/server/whatsapp-rpc/README.md +139 -0
- package/server/whatsapp-rpc/cli.js +95 -0
- package/server/whatsapp-rpc/configs/config.yaml +7 -0
- package/server/whatsapp-rpc/docker-compose.yml +35 -0
- package/server/whatsapp-rpc/docs/API.md +410 -0
- package/server/whatsapp-rpc/go.mod +67 -0
- package/server/whatsapp-rpc/go.sum +203 -0
- package/server/whatsapp-rpc/package.json +30 -0
- package/server/whatsapp-rpc/schema.json +1294 -0
- package/server/whatsapp-rpc/scripts/clean.cjs +66 -0
- package/server/whatsapp-rpc/scripts/cli.js +162 -0
- package/server/whatsapp-rpc/src/go/cmd/server/main.go +91 -0
- package/server/whatsapp-rpc/src/go/config/config.go +49 -0
- package/server/whatsapp-rpc/src/go/rpc/rpc.go +446 -0
- package/server/whatsapp-rpc/src/go/rpc/server.go +112 -0
- package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -0
- package/server/whatsapp-rpc/src/go/whatsapp/messages.go +390 -0
- package/server/whatsapp-rpc/src/go/whatsapp/service.go +2130 -0
- package/server/whatsapp-rpc/src/go/whatsapp/types.go +261 -0
- package/server/whatsapp-rpc/src/python/pyproject.toml +15 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -0
- package/server/whatsapp-rpc/web/app.py +609 -0
- package/server/whatsapp-rpc/web/requirements.txt +6 -0
- package/server/whatsapp-rpc/web/rpc_client.py +427 -0
- package/server/whatsapp-rpc/web/static/openapi.yaml +59 -0
- package/server/whatsapp-rpc/web/templates/base.html +150 -0
- package/server/whatsapp-rpc/web/templates/contacts.html +240 -0
- package/server/whatsapp-rpc/web/templates/dashboard.html +320 -0
- package/server/whatsapp-rpc/web/templates/groups.html +328 -0
- package/server/whatsapp-rpc/web/templates/messages.html +465 -0
- package/server/whatsapp-rpc/web/templates/messaging.html +681 -0
- package/server/whatsapp-rpc/web/templates/send.html +259 -0
- 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
|