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 +0 -0
- api/__init__.py +76 -0
- api/adb.py +85 -0
- api/app_management.py +137 -0
- api/auth.py +136 -0
- api/device.py +168 -0
- api/device_control.py +87 -0
- api/file_management.py +250 -0
- api/platform.py +66 -0
- api/qpilot_appium_control.py +37 -0
- api/qpilot_credits.py +29 -0
- api/qpilot_project.py +51 -0
- api/qpilot_script.py +40 -0
- api/qpilot_steps.py +30 -0
- api/qpilot_test_case.py +53 -0
- api/qpilot_test_suite.py +51 -0
- api/services.py +86 -0
- api/session.py +216 -0
- config.py +40 -0
- mcp_server/__init__.py +1 -0
- mcp_server/server_main.py +66 -0
- mcp_server/shared_mcp.py +4 -0
- mcp_server/tools/appium_actions.py +241 -0
- mcp_server/tools/appium_sdk.py +242 -0
- mcp_server/tools/device_management.py +165 -0
- mcp_server/tools/device_services.py +78 -0
- mcp_server/tools/files.py +103 -0
- mcp_server/tools/get_code.py +43 -0
- mcp_server/tools/qpilot.py +120 -0
- mcp_server/tools/qpilotcredit.py +29 -0
- mcp_server/tools/session_data.py +71 -0
- mcp_server/tools/test_case.py +49 -0
- mcp_server/tools/test_project.py +44 -0
- mcp_server/tools/test_suite.py +43 -0
- pcloudy-0.1.0.dist-info/METADATA +471 -0
- pcloudy-0.1.0.dist-info/RECORD +41 -0
- pcloudy-0.1.0.dist-info/WHEEL +5 -0
- pcloudy-0.1.0.dist-info/entry_points.txt +2 -0
- pcloudy-0.1.0.dist-info/top_level.txt +6 -0
- security.py +56 -0
- utils.py +39 -0
__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
|
+
}
|