0
0
Fork 0

feat(connect_to_device): add loginWaitSeconds for credential dialog

When connecting to a password-protected runtime for the first time,
CODESYS pops a modal credential dialog. login() in some SP versions
returns immediately (without waiting for the dialog), leaving the
application in an undefined state until the user fills it in.

Behaviour:
  - New optional 'loginWaitSeconds' parameter on the tool (default 60,
    range 0-600). After login() returns, the script polls
    online_app.application_state once per second up to that many
    seconds, exiting early when the state lands on a recognisable
    value: run / stop / connected / halt / breakpoint.
  - During the poll, system.delay(1000) pumps the UI message loop so
    the dialog renders and stays interactive while we wait. Once the
    user enters the password and clicks OK, login completes, state
    transitions, and the loop exits.
  - Tool-side IPC timeout extends accordingly (waitSec + 30s headroom)
    so the IPC layer doesn't kill the script mid-dialog.

Concrete case observed today on SP22 Patch 1: prior connect appeared
to "succeed" only because the user happened to be at the keyboard and
manually entered the password while the dialog was up. Without the
poll, an unattended run would race past the dialog into a half-state.

Future companion: download_to_device hits the same login path and
should get the same parameter; will follow as a separate commit.

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:31:56 +02:00
parent e862846164
commit eee8ce2d1e
2 changed files with 36 additions and 10 deletions

View file

@ -1,7 +1,10 @@
import sys, scriptengine as script_engine, os, traceback
LOGIN_WAIT_SECONDS = {LOGIN_WAIT_SECONDS}
try:
print("DEBUG: connect_to_device script: Project='%s'" % PROJECT_FILE_PATH)
print("DEBUG: connect_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)
@ -65,13 +68,28 @@ try:
if not logged_in:
raise RuntimeError("All login() call shapes failed. Last error: %s" % last_err)
print("DEBUG: Login successful.")
print("DEBUG: login() returned. Waiting up to %d seconds for state to stabilise" % LOGIN_WAIT_SECONDS)
print("DEBUG: (CODESYS may pop a credential dialog -- enter device password if prompted.)")
# Check connection state
state = "connected"
if hasattr(online_app, 'application_state'):
# Poll application_state. CODESYS shows a modal credential dialog the
# first time you log into a device with a password; login() may return
# immediately while the dialog is still up, leaving the application in
# an undefined state. Pump the message loop via system.delay() so the
# dialog renders and the user has time to fill it in. Exit early once
# the state lands on a recognisable terminal value.
STABLE_STATES = ('run', 'stop', 'connected', 'halt', 'breakpoint')
state = "unknown"
for elapsed in range(LOGIN_WAIT_SECONDS):
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:
state = str(online_app.application_state)
script_engine.system.delay(1000)
except Exception:
pass

View file

@ -855,17 +855,25 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
s.tool(
'connect_to_device',
'Connects (logs in) to the PLC runtime for the active application. Requires a configured device/gateway in the project.',
'Connects (logs in) to the PLC runtime for the active application. Requires a configured device/gateway in the project. The first connect to a password-protected runtime pops a credential dialog in CODESYS that the user must fill in -- the loginWaitSeconds parameter controls how long the script polls for state stabilisation while that dialog is up.',
{
projectFilePath: z.string().describe("Path to the project file."),
loginWaitSeconds: z.number().int().min(0).max(600).optional().describe("Seconds to wait for the application state to stabilise after login() returns. Used to give the user time to fill in a credential dialog. 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(
'connect_to_device', { PROJECT_FILE_PATH: escaped },
'connect_to_device',
{
PROJECT_FILE_PATH: escaped,
LOGIN_WAIT_SECONDS: String(waitSec),
},
['ensure_project_open', 'ensure_online_connection']
);
const result = await executor.executeScript(script, 60_000);
// Tool-side timeout = wait window + 30s headroom for actual login work
const ipcTimeoutMs = (waitSec + 30) * 1000;
const result = await executor.executeScript(script, ipcTimeoutMs);
return formatToolResponse(result, `Connected to device for ${args.projectFilePath}.`);
}
);