diff --git a/src/scripts/download_to_device.py b/src/scripts/download_to_device.py index 9568b53..aff942c 100644 --- a/src/scripts/download_to_device.py +++ b/src/scripts/download_to_device.py @@ -1,28 +1,84 @@ import sys, scriptengine as script_engine, os, traceback +LOGIN_WAIT_SECONDS = {LOGIN_WAIT_SECONDS} + try: - print("DEBUG: download_to_device script: Project='%s'" % PROJECT_FILE_PATH) + print("DEBUG: download_to_device script: Project='%s', LoginWaitSec=%d" % ( + PROJECT_FILE_PATH, LOGIN_WAIT_SECONDS)) primary_project = ensure_project_open(PROJECT_FILE_PATH) online_app, target_app = ensure_online_connection(primary_project) app_name = getattr(target_app, 'get_name', lambda: "Unknown")() - # Login with online change option if available, then download + # Login. Same SP-version drift as connect_to_device: TryOnlineChange + # is gone, login() now requires (OnlineChangeOption, bool) on SP21+. + # Use the same defensive probe + post-login wait pattern. print("DEBUG: Logging in for download...") - - if hasattr(online_app, 'login'): - if hasattr(script_engine, 'OnlineChangeOption'): - try: - online_app.login(script_engine.OnlineChangeOption.TryOnlineChange) - print("DEBUG: Logged in with TryOnlineChange.") - except Exception as e: - print("DEBUG: Login with OnlineChangeOption failed, trying plain login: %s" % e) - online_app.login() - else: - online_app.login() - else: + if not hasattr(online_app, 'login'): raise TypeError("Online application does not support login().") + enum_candidates = [] + if hasattr(script_engine, 'OnlineChangeOption'): + oc = script_engine.OnlineChangeOption + oc_members = sorted([m for m in dir(oc) if not m.startswith('_')]) + print("DEBUG: OnlineChangeOption members: %s" % oc_members) + # For download we prefer 'WithDownload' / 'ForceDownload' over 'Try' + for preferred in ('WithDownload', 'ForceDownload', 'Try', + 'TryOnlineChange', 'OnlineChangeOnly', 'None_', 'None'): + if preferred in oc_members: + try: + enum_candidates.append((preferred, getattr(oc, preferred))) + except Exception: + pass + for m in oc_members: + if m not in [n for n, _ in enum_candidates]: + try: + enum_candidates.append((m, getattr(oc, m))) + except Exception: + pass + + call_shapes = [] + for nm, val in enum_candidates: + call_shapes.append(("login(%s, False)" % nm, (val, False))) + call_shapes.append(("login(%s, True)" % nm, (val, True))) + call_shapes.append(("login(%s)" % nm, (val,))) + call_shapes.append(("login(False)", (False,))) + call_shapes.append(("login(True)", (True,))) + call_shapes.append(("login()", ())) + + last_err = None + logged_in = False + for desc, args in call_shapes: + try: + online_app.login(*args) + print("DEBUG: %s succeeded" % desc) + logged_in = True + break + except Exception as e: + last_err = e + print("DEBUG: %s failed: %s: %s" % (desc, type(e).__name__, e)) + + if not logged_in: + raise RuntimeError("All login() call shapes failed. Last error: %s" % last_err) + + # Wait for state to stabilise (credential dialog handling) + print("DEBUG: login() returned. Waiting up to %d seconds for state to stabilise" % LOGIN_WAIT_SECONDS) + STABLE_STATES = ('run', 'stop', 'connected', 'halt', 'breakpoint') + for elapsed in range(LOGIN_WAIT_SECONDS): + state = "unknown" + if hasattr(online_app, 'application_state'): + try: + state = str(online_app.application_state) + except Exception: + pass + if state.lower() in STABLE_STATES: + print("DEBUG: state stabilised at '%s' after %ds" % (state, elapsed)) + break + try: + script_engine.system.delay(1000) + except Exception: + pass + # Download print("DEBUG: Calling download()...") if hasattr(online_app, 'download'): diff --git a/src/server.ts b/src/server.ts index f8290f5..44faa53 100644 --- a/src/server.ts +++ b/src/server.ts @@ -996,17 +996,25 @@ export async function startMcpServer(config: ServerConfig): Promise { s.tool( 'download_to_device', - 'Downloads the compiled application to the PLC device. Attempts online change first, falls back to full download.', + 'Downloads the compiled application to the PLC device. Attempts online change first, falls back to full download. Same login-dialog handling as connect_to_device: loginWaitSeconds controls how long the script waits for state stabilisation if a credential dialog pops up.', { projectFilePath: z.string().describe("Path to the project file."), + loginWaitSeconds: z.number().int().min(0).max(600).optional().describe("Seconds to wait for application state to stabilise after login() returns. Default: 60. Range 0-600."), }, - async (args: { projectFilePath: string }) => { + async (args: { projectFilePath: string; loginWaitSeconds?: number }) => { const escaped = resolvePath(args.projectFilePath, workspaceDir); + const waitSec = args.loginWaitSeconds ?? 60; const script = scriptManager.prepareScriptWithHelpers( - 'download_to_device', { PROJECT_FILE_PATH: escaped }, + 'download_to_device', + { + PROJECT_FILE_PATH: escaped, + LOGIN_WAIT_SECONDS: String(waitSec), + }, ['ensure_project_open', 'ensure_online_connection'] ); - const result = await executor.executeScript(script, 120_000); + // Tool-side timeout = wait window + 120s headroom for the actual download + const ipcTimeoutMs = (waitSec + 120) * 1000; + const result = await executor.executeScript(script, ipcTimeoutMs); return formatToolResponse(result, `Application downloaded to device for ${args.projectFilePath}.`); } );