0
0
Fork 0

fix(download_to_device): port login() probe + loginWaitSeconds from connect

Same SP21+/SP22 login() drift as connect_to_device:
  - 'TryOnlineChange' is gone from OnlineChangeOption
  - login() now requires (OnlineChangeOption, bool) -- two positional
The fork hard-coded the old call shape; observed today on SP22 Patch 1
with the runtime up and the project compiled, every download_to_device
attempt failed with "login() takes exactly 2 arguments (0 given)".

Fix mirrors e862846 / eee8ce2:
  - Probe OnlineChangeOption members at runtime (priority order tuned
    for download: WithDownload / ForceDownload come first, since
    'download' implies a write).
  - Try (enum, False) / (enum, True) / (enum,) / bool / no-arg shapes.
  - Add LOGIN_WAIT_SECONDS post-login state-stabilisation poll for the
    credential dialog (default 60s, configurable 0-600).
  - Tool-side IPC timeout = waitSec + 120s headroom for the actual
    download work (download is heavier than connect, hence 120 vs 30).

The download itself is still done via the existing fall-through:
online_app.download() if exposed, else create_boot_application().
On SP22 the dir() shows source_download + create_boot_application (no
'download'), so the create_boot_application path is what runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-25 18:40:26 +02:00
parent 64906c4ab5
commit b3bf4a83d5
2 changed files with 82 additions and 18 deletions

View file

@ -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'):

View file

@ -996,17 +996,25 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
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}.`);
}
);