pcloudy 0.1.0__py3-none-any.whl

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.
__init__.py ADDED
File without changes
api/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ from .auth import Auth
2
+ from .device import Device
3
+ from .file_management import FileManagement
4
+ from .services import Services
5
+ from .app_management import AppManagement
6
+ from .session import Session
7
+ from .adb import Adb
8
+ from .platform import Platform
9
+ from .device_control import DeviceControl
10
+ from .qpilot_credits import QPilotCredits
11
+ from .qpilot_project import QPilotProject
12
+ from .qpilot_test_suite import QPilotTestSuite
13
+ from .qpilot_test_case import QPilotTestCase
14
+ from .qpilot_steps import QPilotSteps
15
+ from .qpilot_appium_control import QPilotAppiumControl
16
+ from .qpilot_script import QPilotScript
17
+ import os
18
+ import httpx
19
+ from config import Config, logger
20
+ from dotenv import load_dotenv
21
+
22
+ # Ensure .env is loaded if not already
23
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
24
+ load_dotenv(os.path.join(project_root, '.env'))
25
+
26
+ class PCloudyAPI(
27
+ Auth,
28
+ Device,
29
+ FileManagement,
30
+ Services,
31
+ AppManagement,
32
+ Session,
33
+ Adb,
34
+ Platform,
35
+ DeviceControl,
36
+ QPilotCredits,
37
+ QPilotProject,
38
+ QPilotTestSuite,
39
+ QPilotTestCase,
40
+ QPilotSteps,
41
+ QPilotAppiumControl,
42
+ QPilotScript
43
+ ):
44
+ def __init__(self, base_url=None):
45
+ Auth.__init__(self)
46
+ Device.__init__(self)
47
+ FileManagement.__init__(self)
48
+ Services.__init__(self)
49
+ AppManagement.__init__(self)
50
+ Session.__init__(self)
51
+ Adb.__init__(self)
52
+ Platform.__init__(self)
53
+ DeviceControl.__init__(self)
54
+ QPilotCredits.__init__(self)
55
+ QPilotProject.__init__(self)
56
+ QPilotTestSuite.__init__(self)
57
+ QPilotTestCase.__init__(self)
58
+ QPilotSteps.__init__(self)
59
+ QPilotAppiumControl.__init__(self)
60
+ QPilotScript.__init__(self)
61
+ self.username = os.environ.get("PCLOUDY_USERNAME") or os.environ.get("PLOUDY_USERNAME")
62
+ self.api_key = os.environ.get("PCLOUDY_API_KEY") or os.environ.get("PLOUDY_API_KEY")
63
+ if not self.username or not self.api_key:
64
+ logger.warning("PCLOUDY_USERNAME or PCLOUDY_API_KEY not set. Check your .env file and environment.")
65
+ self.base_url = base_url or Config.PCLOUDY_BASE_URL
66
+ self.client = httpx.AsyncClient(timeout=Config.REQUEST_TIMEOUT)
67
+ self.rid = None
68
+ logger.info("PCloudyAPI initialized (modular)")
69
+
70
+ async def close(self):
71
+ """Close the HTTP client."""
72
+ try:
73
+ await self.client.aclose()
74
+ logger.info("HTTP client closed")
75
+ except Exception as e:
76
+ logger.error(f"Error closing HTTP client: {str(e)}")
api/adb.py ADDED
@@ -0,0 +1,85 @@
1
+ from config import Config, logger
2
+ import httpx
3
+ import json
4
+ from utils import encode_auth, parse_response
5
+
6
+ class Adb:
7
+ async def execute_adb_command(self, rid: int, adb_command: str):
8
+ token = await self.get_token()
9
+ if not adb_command.strip():
10
+ raise ValueError("ADB command cannot be empty")
11
+ original_command = adb_command.strip().strip('"').strip("'")
12
+ # Ensure 'adb ' prefix is present for backend compatibility
13
+ if not original_command.lower().startswith('adb '):
14
+ send_command = f'adb {original_command}'
15
+ logger.info(f"Added 'adb' prefix: sending '{send_command}' to backend.")
16
+ else:
17
+ send_command = original_command
18
+ logger.info(f"Executing ADB command on RID {rid}: {send_command}")
19
+ url = f"{self.base_url}/execute_adb"
20
+ payload = {
21
+ "token": token,
22
+ "rid": rid,
23
+ "adbCommand": send_command
24
+ }
25
+ headers = {"Content-Type": "application/json"}
26
+ timeout_config = httpx.Timeout(connect=30.0, read=120.0, write=30.0, pool=30.0)
27
+ async with httpx.AsyncClient(timeout=timeout_config) as client:
28
+ logger.debug(f"Sending ADB request to: {url}")
29
+ logger.debug(f"Payload: {json.dumps(payload, indent=2)}")
30
+ response = await client.post(url, json=payload, headers=headers)
31
+ response.raise_for_status()
32
+ raw_data = response.json()
33
+ logger.info(f"Raw ADB response: {json.dumps(raw_data, indent=2)}")
34
+ if isinstance(raw_data, dict):
35
+ result = raw_data.get("result", raw_data)
36
+ status_code = result.get("code", 0)
37
+ message = result.get("msg", "")
38
+ output_content = None
39
+ output_source = None
40
+ for field_name in ["adbreply", "output", "reply", "response", "data", "result"]:
41
+ if field_name in result and result[field_name] is not None:
42
+ output_content = result[field_name]
43
+ output_source = field_name
44
+ break
45
+ if output_content is not None:
46
+ formatted_output = str(output_content)
47
+ if "\n" in formatted_output:
48
+ formatted_output = formatted_output.replace("\n", "\n")
49
+ formatted_output = formatted_output.strip()
50
+ if not formatted_output:
51
+ formatted_output = "[Command executed successfully but returned empty output]"
52
+ else:
53
+ formatted_output = "[No output returned from device]"
54
+ logger.warning(f"No output found in response fields. Available keys: {list(result.keys())}")
55
+ is_success = status_code == 200 and "Invalid Command" not in formatted_output
56
+ if is_success:
57
+ logger.info(f"ADB command successful. Output source: {output_source}, Length: {len(formatted_output)}")
58
+ return {
59
+ "success": True,
60
+ "output": formatted_output,
61
+ "command": send_command,
62
+ "rid": rid,
63
+ "status_code": status_code,
64
+ "message": message,
65
+ "output_source": output_source
66
+ }
67
+ else:
68
+ error_msg = f"ADB command failed: {formatted_output}"
69
+ logger.error(error_msg)
70
+ return {
71
+ "success": False,
72
+ "error": error_msg,
73
+ "command": send_command,
74
+ "rid": rid,
75
+ "status_code": status_code,
76
+ "raw_response": raw_data
77
+ }
78
+ else:
79
+ logger.error(f"Unexpected response format: {type(raw_data)}")
80
+ return {
81
+ "success": False,
82
+ "error": f"Unexpected response format: {type(raw_data)}",
83
+ "command": send_command,
84
+ "rid": rid
85
+ }
api/app_management.py ADDED
@@ -0,0 +1,137 @@
1
+ from config import Config, logger
2
+ from utils import encode_auth, parse_response
3
+ import httpx
4
+ import webbrowser
5
+
6
+ class AppManagement:
7
+ async def install_and_launch_app(self, rid: int, filename: str, grant_all_permissions: bool = True, app_package_name: str = None):
8
+ token = await self.get_token()
9
+ url = f"{self.base_url}/install_app"
10
+ payload = {
11
+ "token": token,
12
+ "rid": rid,
13
+ "filename": filename,
14
+ "grant_all_permissions": grant_all_permissions
15
+ }
16
+ headers = {"Content-Type": "application/json"}
17
+ response = await self.client.post(url, json=payload, headers=headers)
18
+ response.raise_for_status()
19
+ result = parse_response(response)
20
+ if result.get("code") == 200 and result.get("msg") == "success":
21
+ package = result.get("package", "")
22
+ logger.info(f"App '{filename}' installed and launched successfully on RID: {rid}")
23
+ response_content = [
24
+ {"type": "text", "text": f"✅ App '{filename}' installed and launched successfully on RID: {rid}"}
25
+ ]
26
+ if package:
27
+ response_content.append({"type": "text", "text": f"📱 Package: {package}"})
28
+ try:
29
+ logger.info(f"Getting device page URL for RID: {rid}")
30
+ url_result = await self.get_device_page_url(rid)
31
+ if not url_result.get("isError", True):
32
+ device_url = url_result.get("content", [{}])[0].get("text", "")
33
+ if device_url:
34
+ webbrowser.open(device_url)
35
+ response_content.append({"type": "text", "text": f"🌐 Device page opened in browser: {device_url}"})
36
+ logger.info(f"Device page opened in browser: {device_url}")
37
+ else:
38
+ response_content.append({"type": "text", "text": "⚠️ Could not retrieve device page URL"})
39
+ else:
40
+ response_content.append({"type": "text", "text": "⚠️ Could not retrieve device page URL"})
41
+ except Exception as url_error:
42
+ logger.warning(f"Failed to open device page URL: {str(url_error)}")
43
+ response_content.append({"type": "text", "text": f"⚠️ Could not open device page: {str(url_error)}"})
44
+ return {
45
+ "content": response_content,
46
+ "isError": False
47
+ }
48
+ else:
49
+ logger.error(f"Install and launch failed: {result}")
50
+ return {
51
+ "content": [{"type": "text", "text": f"Install and launch failed: {result}"}],
52
+ "isError": True
53
+ }
54
+
55
+ async def resign_ipa(self, filename: str, force_resign: bool = False):
56
+ await self.check_token_validity()
57
+ from security import extract_package_name_hint
58
+ if not force_resign:
59
+ logger.info(f"Checking if resigned version of '{filename}' already exists...")
60
+ try:
61
+ base_name = filename.rsplit('.', 1)[0]
62
+ expected_resigned_names = [
63
+ f"{base_name}_resign.ipa",
64
+ f"{base_name}-resign.ipa",
65
+ f"resign_{base_name}.ipa",
66
+ f"{filename}_resign",
67
+ f"resign_{filename}"
68
+ ]
69
+ cloud_apps_result = await self.list_cloud_apps(limit=100, filter_type="all")
70
+ if not cloud_apps_result.get("isError", True):
71
+ cloud_content = cloud_apps_result.get("content", [])
72
+ if cloud_content:
73
+ cloud_files_text = str(cloud_content[0].get("text", "")).lower()
74
+ for resigned_name in expected_resigned_names:
75
+ if resigned_name.lower() in cloud_files_text:
76
+ logger.warning(f"Resigned version '{resigned_name}' already exists in cloud")
77
+ return {
78
+ "content": [
79
+ {"type": "text", "text": f"⚠️ A resigned version of '{filename}' already exists in the cloud drive"},
80
+ {"type": "text", "text": f"🔍 Found existing resigned file: {resigned_name}"},
81
+ {"type": "text", "text": "💡 To resign anyway (replace existing), call: resign_ipa(filename=\"" + filename + "\", force_resign=True)"},
82
+ {"type": "text", "text": "📋 To see all cloud files, use: list_cloud_apps()"}
83
+ ],
84
+ "isError": False,
85
+ "duplicate_detected": True,
86
+ "existing_resigned_file": resigned_name
87
+ }
88
+ except Exception as check_error:
89
+ logger.warning(f"Could not check for existing resigned files: {str(check_error)}")
90
+ headers = {"Content-Type": "application/json"}
91
+ token = await self.get_token()
92
+ url_initiate = f"{self.base_url}/resign/initiate"
93
+ payload_initiate = {"token": token, "filename": filename}
94
+ response = await self.client.post(url_initiate, json=payload_initiate, headers=headers)
95
+ response.raise_for_status()
96
+ result = parse_response(response)
97
+ resign_token = result.get("resign_token")
98
+ resign_filename = result.get("resign_filename")
99
+ if not resign_token or not resign_filename:
100
+ raise Exception(f"Failed to initiate resigning IPA. API response: {result}")
101
+ logger.info(f"Resigning IPA '{filename}' - this may take up to 60 seconds...")
102
+ url_progress = f"{self.base_url}/resign/progress"
103
+ for _ in range(30):
104
+ payload_progress = {
105
+ "token": token,
106
+ "resign_token": resign_token,
107
+ "filename": filename
108
+ }
109
+ response = await self.client.post(url_progress, json=payload_progress, headers=headers)
110
+ response.raise_for_status()
111
+ result = parse_response(response)
112
+ resign_status = result.get("resign_status")
113
+ if resign_status == 100:
114
+ break
115
+ import asyncio
116
+ await asyncio.sleep(2)
117
+ url_download = f"{self.base_url}/resign/download"
118
+ payload_download = {
119
+ "token": token,
120
+ "resign_token": resign_token,
121
+ "filename": filename
122
+ }
123
+ response = await self.client.post(url_download, json=payload_download, headers=headers)
124
+ response.raise_for_status()
125
+ result = parse_response(response)
126
+ resigned_file = result.get("resign_file")
127
+ if not resigned_file:
128
+ raise Exception(f"Failed to download resigned IPA. API response: {result}")
129
+ resign_message = f"IPA file '{filename}' has been resigned successfully"
130
+ if force_resign:
131
+ resign_message += " (replaced existing resigned version)"
132
+ logger.info(resign_message)
133
+ return {
134
+ "content": [{"type": "text", "text": resign_message}],
135
+ "isError": False,
136
+ "resigned_file": resigned_file
137
+ }
api/auth.py ADDED
@@ -0,0 +1,136 @@
1
+ """
2
+ Authentication Mixin for pCloudy MCP Server
3
+
4
+ Provides authentication and token management for the PCloudyAPI class.
5
+ - authenticate: Authenticates with pCloudy using username and API key.
6
+ - check_token_validity: Ensures the token is valid and refreshes if expired.
7
+
8
+ Intended to be used as a mixin in the modular API architecture.
9
+ """
10
+
11
+ import time
12
+ import os
13
+ # ...existing code...
14
+ from utils import encode_auth, parse_response
15
+ from config import Config, logger
16
+ import httpx
17
+
18
+ class Auth:
19
+ TOKEN_REFRESH_THRESHOLD = 24 * 60 * 60 # 24 hours in seconds
20
+
21
+ def __init__(self):
22
+ self.username = None
23
+ self.api_key = None
24
+ self.base_url = None
25
+ self._auth_token = None
26
+ self._token_timestamp = None
27
+ self.client = None
28
+ self._db_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "pcloudy_tokens.db")
29
+ self._init_db()
30
+
31
+ def _init_db(self):
32
+ """Initialize the SQLite database and tokens table"""
33
+ import sqlite3
34
+ conn = sqlite3.connect(self._db_path)
35
+ c = conn.cursor()
36
+ c.execute("""
37
+ CREATE TABLE IF NOT EXISTS tokens (
38
+ username TEXT PRIMARY KEY,
39
+ token TEXT,
40
+ timestamp REAL
41
+ )
42
+ """)
43
+ conn.commit()
44
+ conn.close()
45
+
46
+ def _save_token_to_db(self):
47
+ """Save token to SQLite DB, encoding the token value"""
48
+ if not self._auth_token or not self._token_timestamp:
49
+ return
50
+ import sqlite3, base64
51
+ try:
52
+ encoded_token = base64.b64encode(self._auth_token.encode('utf-8')).decode('utf-8')
53
+ conn = sqlite3.connect(self._db_path)
54
+ c = conn.cursor()
55
+ c.execute("REPLACE INTO tokens (username, token, timestamp) VALUES (?, ?, ?)",
56
+ (self.username, encoded_token, self._token_timestamp))
57
+ conn.commit()
58
+ conn.close()
59
+ logger.info(f"Token saved to DB for user: {self.username}")
60
+ except Exception as e:
61
+ logger.error(f"Failed to save token to DB: {str(e)}")
62
+
63
+ def _load_token_from_db(self):
64
+ """Load token from SQLite DB and decode the token value"""
65
+ import sqlite3, base64
66
+ try:
67
+ conn = sqlite3.connect(self._db_path)
68
+ c = conn.cursor()
69
+ c.execute("SELECT token, timestamp FROM tokens WHERE username=?", (self.username,))
70
+ row = c.fetchone()
71
+ conn.close()
72
+ if row:
73
+ encoded_token, self._token_timestamp = row
74
+ self._auth_token = base64.b64decode(encoded_token.encode('utf-8')).decode('utf-8')
75
+ logger.info("Token loaded from DB successfully")
76
+ return True
77
+ else:
78
+ return False
79
+ except Exception as e:
80
+ logger.error(f"Failed to load token from DB: {str(e)}")
81
+ return False
82
+
83
+ async def authenticate(self) -> str:
84
+ """
85
+ Authenticate with the pCloudy API and store the token.
86
+ Raises ValueError if credentials are missing or authentication fails.
87
+ """
88
+ try:
89
+ if not self.username or not self.api_key:
90
+ logger.error("PCLOUDY_USERNAME or PCLOUDY_API_KEY environment variable not set.")
91
+ raise ValueError("PCLOUDY_USERNAME or PCLOUDY_API_KEY environment variable not set.")
92
+ logger.info("Authenticating with pCloudy")
93
+ url = f"{self.base_url}/access"
94
+ auth = encode_auth(self.username, self.api_key)
95
+ headers = {"Authorization": f"Basic {auth}"}
96
+ response = await self.client.get(url, headers=headers)
97
+ response.raise_for_status()
98
+ result = parse_response(response)
99
+ self._auth_token = result.get("token")
100
+ if not self._auth_token:
101
+ logger.error("Authentication failed: No token received")
102
+ raise ValueError("Authentication failed: No token received")
103
+ self._token_timestamp = time.time()
104
+ # Save token to DB for persistence
105
+ self._save_token_to_db()
106
+ logger.info("Authentication successful")
107
+ return self._auth_token
108
+ except httpx.RequestError as e:
109
+ logger.error(f"Authentication request failed: {str(e)}")
110
+ raise
111
+ except Exception as e:
112
+ logger.error(f"Authentication error: {str(e)}")
113
+ raise
114
+
115
+ async def get_token(self) -> str:
116
+ """
117
+ Get a valid authentication token, refreshing if expired (every 8 hours).
118
+ This is the only method that should be used to access the token.
119
+ """
120
+ # Try to load token from DB first
121
+ if not self._auth_token:
122
+ self._load_token_from_db()
123
+ # If missing or expired, renew
124
+ if (not self._auth_token or not self._token_timestamp or
125
+ (time.time() - float(self._token_timestamp)) > self.TOKEN_REFRESH_THRESHOLD):
126
+ logger.info("Token missing or expired, generating new token...")
127
+ await self.authenticate()
128
+ return self._auth_token
129
+
130
+ # Optionally, keep check_token_validity for legacy use
131
+ async def check_token_validity(self) -> str:
132
+ """
133
+ Ensure the authentication token is valid, refreshing if expired.
134
+ Raises ValueError if not authenticated.
135
+ """
136
+ return await self.get_token()
api/device.py ADDED
@@ -0,0 +1,168 @@
1
+ """
2
+ Device Management Mixin for pCloudy MCP Server
3
+
4
+ Provides device management operations for the PCloudyAPI class:
5
+ - get_devices_list: List available devices for a platform
6
+ - book_device: Book a device by ID
7
+ - release_device: Release a booked device by RID
8
+
9
+ Intended to be used as a mixin in the modular API architecture.
10
+ """
11
+
12
+ from config import Config, logger
13
+ from utils import encode_auth, parse_response
14
+ import httpx
15
+ import asyncio
16
+
17
+ class Device:
18
+ async def get_devices_list(self, platform: str = Config.DEFAULT_PLATFORM, duration: int = Config.DEFAULT_DURATION, available_now: bool = True):
19
+ """
20
+ List available devices for a given platform and duration.
21
+ Returns a dict with device models and availability.
22
+ """
23
+ try:
24
+ platform = platform.lower().strip()
25
+ if platform not in Config.VALID_PLATFORMS:
26
+ logger.error(f"Invalid platform: {platform}. Must be one of {Config.VALID_PLATFORMS}")
27
+ raise ValueError(f"Invalid platform: {platform}. Must be one of {Config.VALID_PLATFORMS}")
28
+ await self.check_token_validity()
29
+ logger.info(f"Getting device list for platform {platform}")
30
+ url = f"{self.base_url}/devices"
31
+ token = await self.get_token()
32
+ payload = {
33
+ "token": token,
34
+ "platform": platform,
35
+ "duration": duration,
36
+ "available_now": str(available_now).lower()
37
+ }
38
+ headers = {"Content-Type": "application/json"}
39
+ response = await self.client.post(url, json=payload, headers=headers)
40
+ response.raise_for_status()
41
+ result = parse_response(response)
42
+ logger.info(f"Retrieved {len(result.get('models', []))} devices for {platform}")
43
+ return result
44
+ except httpx.RequestError as e:
45
+ logger.error(f"Device list request failed: {str(e)}")
46
+ raise
47
+ except Exception as e:
48
+ logger.error(f"Error getting device list: {str(e)}")
49
+ raise
50
+
51
+ async def book_device(self, device_id: str, duration: int = Config.DEFAULT_DURATION, auto_start_services: bool = True):
52
+ """
53
+ Book a device by its ID. Optionally auto-starts device services.
54
+ Returns booking info and optionally enhanced content.
55
+ """
56
+ try:
57
+ await self.check_token_validity()
58
+ logger.info(f"Booking device with ID {device_id}")
59
+ url = f"{self.base_url}/book_device"
60
+ token = await self.get_token()
61
+ payload = {
62
+ "token": token,
63
+ "id": device_id,
64
+ "duration": duration
65
+ }
66
+ headers = {"Content-Type": "application/json"}
67
+ response = await self.client.post(url, json=payload, headers=headers)
68
+ response.raise_for_status()
69
+ result = parse_response(response)
70
+ rid = result.get('rid')
71
+ logger.info(f"Device booked successfully. RID: {rid}")
72
+ response_content = [
73
+ {"type": "text", "text": f"\u2705 Device booked successfully. RID: {rid}"}
74
+ ]
75
+ if auto_start_services and rid:
76
+ try:
77
+ logger.info(f"Auto-starting device services for RID: {rid}")
78
+ await asyncio.sleep(2)
79
+ services_result = await self.start_device_services(rid)
80
+ if not services_result.get("isError", True):
81
+ response_content.extend(services_result.get("content", []))
82
+ logger.info(f"Device services started successfully for RID: {rid}")
83
+ else:
84
+ response_content.append({
85
+ "type": "text",
86
+ "text": "\u26a0\ufe0f Device services failed to start automatically, but device is booked successfully"
87
+ })
88
+ response_content.append({
89
+ "type": "text",
90
+ "text": "\ud83d\udca1 You can manually start services with: start_device_services(rid=\"" + str(rid) + "\")"
91
+ })
92
+ logger.warning(f"Failed to auto-start device services: {services_result}")
93
+ except Exception as service_error:
94
+ logger.warning(f"Failed to auto-start device services: {str(service_error)}")
95
+ response_content.append({
96
+ "type": "text",
97
+ "text": "\u26a0\ufe0f Device services failed to start automatically, but device is booked successfully"
98
+ })
99
+ response_content.append({
100
+ "type": "text",
101
+ "text": "\ud83d\udca1 You can manually start services with: start_device_services(rid=\"" + str(rid) + "\")"
102
+ })
103
+ enhanced_result = result.copy()
104
+ enhanced_result["enhanced_content"] = response_content
105
+ return enhanced_result
106
+ except httpx.RequestError as e:
107
+ logger.error(f"Device booking request failed: {str(e)}")
108
+ raise
109
+ except Exception as e:
110
+ logger.error(f"Error booking device: {str(e)}")
111
+ raise
112
+
113
+ async def release_device(self, rid: int, auto_download: bool = False):
114
+ """
115
+ Release a booked device by its RID. Optionally auto-downloads session data.
116
+ Returns a dict with release status and messages.
117
+ """
118
+ try:
119
+ await self.check_token_validity()
120
+ logger.info(f"Releasing device with RID: {rid} (this may take 10-20 seconds)")
121
+ url = f"{self.base_url}/release_device"
122
+ token = await self.get_token()
123
+ payload = {"token": token, "rid": int(rid)}
124
+ headers = {"Content-Type": "application/json"}
125
+ async with httpx.AsyncClient(timeout=30.0) as release_client:
126
+ response = await release_client.post(url, json=payload, headers=headers)
127
+ response.raise_for_status()
128
+ result = parse_response(response)
129
+ if result.get("code") == 200 and result.get("msg") == "success":
130
+ logger.info(f"Device {rid} released successfully")
131
+ return {
132
+ "content": [{"type": "text", "text": f"\u2705 Device {rid} released successfully"}],
133
+ "isError": False
134
+ }
135
+ else:
136
+ # Handle different error response formats from pCloudy API
137
+ def find_error(d):
138
+ if isinstance(d, dict):
139
+ if 'error' in d and d['error']:
140
+ return d['error']
141
+ for v in d.values():
142
+ found = find_error(v)
143
+ if found:
144
+ return found
145
+ elif isinstance(d, list):
146
+ for item in d:
147
+ found = find_error(item)
148
+ if found:
149
+ return found
150
+ return None
151
+ error_msg = find_error(result) or result.get('msg') or "Unknown error"
152
+ logger.error(f"Device release failed: {error_msg}")
153
+ return {
154
+ "content": [{"type": "text", "text": f"Device release failed: {error_msg}"}],
155
+ "isError": True
156
+ }
157
+ except httpx.TimeoutException:
158
+ logger.error(f"Release device request timed out after 30 seconds for RID: {rid}")
159
+ return {
160
+ "content": [{"type": "text", "text": f"Release device request timed out. The device may still be released, but the server was slow to respond. Please check device status."}],
161
+ "isError": True
162
+ }
163
+ except Exception as e:
164
+ logger.error(f"Error releasing device {rid}: {str(e)}")
165
+ return {
166
+ "content": [{"type": "text", "text": f"Error releasing device: {str(e)}"}],
167
+ "isError": True
168
+ }