Two related v5-sweep fixes for the online/runtime tool family: 1. Auto-login helper for headless mode In headless mode each MCP call spawns a fresh CODESYS --noUI process, so the login state established by connect_to_device dies before the next call. Pre-fix, only connect_to_device and download_to_device did their own login(); the other four (start_stop_application, read_variable, write_variable, read_running_version_online) silently failed in headless with 'Application not logged in.' (start/stop) or 'Invalid expression' (read/write). They worked in persistent mode only because the login carried across calls. Added ensure_logged_in(online_app, login_wait_seconds=30) to ensure_online_connection.py. Idempotent: short-circuits via online_app.is_logged_in (persistent mode is a no-op, no extra login roundtrip). When not logged in, runs the same enum-probe + call-shape probe + STABLE_STATES settle-wait pattern as connect_to_device.py. Added to start_stop_application.py, read_variable.py, write_variable.py, read_running_version_online.py. 2. _MCP_PROJECT_VERSION GVL emitted as plain VAR_GLOBAL, not CONSTANT CODESYS inlines VAR_GLOBAL CONSTANT scalars at compile time and strips them from the online symbol table. The whole point of _MCP_PROJECT_VERSION.sVersion is to be readable live from the running PLC, so CONSTANT was the wrong storage class. read_running_version_online failed against EVERY project bumped via the old template -- 'Invalid expression' on the runtime read. Dropped CONSTANT from VERSION_GVL_DECLARATION_TEMPLATE in bump_project_version.py. Existing projects auto-migrate on the next bump because maintain_version_gvl()'s existing-GVL branch overwrites textual_declaration with the (now non-CONSTANT) template. The string is still effectively read-only at runtime -- only the bump tool updates it. read_running_version_online.py also got a more precise error message that explicitly fingerprints the 'Invalid expression' failure mode and points at the CONSTANT root cause. Useful for any user landing on a project that pre-dates this fix. Verified end-to-end against local CODESYS Control Win V3 (PLATEA, port 11740) on MCPTest2 v1.3.4.0: - connect_to_device, get_application_state, download_to_device, start_stop_application (both directions), read_variable (PLC_PRG.watchdog1 = 225 ticking), write_variable (200 -> 204 in 4s proves write took), disconnect_from_device: all 7 PASS. - read_running_version_online failure reproduced (CONSTANT inlined), fix landed -- next bump on MCPTest2 will validate. 37/37 unit/integration tests green. TEST_OVERVIEW.md updated with the v5 device sweep, with the headless-mode deep-dive, and with the broken-by-design notes on read_running_version_online.
201 lines
8.4 KiB
Python
201 lines
8.4 KiB
Python
import traceback
|
|
|
|
# --- Function to ensure an online connection to the active application ---
|
|
def ensure_online_connection(primary_project):
|
|
"""Get or create an online application connection for the active application."""
|
|
print("DEBUG: Ensuring online connection...")
|
|
|
|
target_app = None
|
|
app_name = "N/A"
|
|
|
|
# Try getting active application
|
|
try:
|
|
target_app = primary_project.active_application
|
|
if target_app:
|
|
app_name = getattr(target_app, 'get_name', lambda: "Unnamed App")()
|
|
except Exception as e:
|
|
print("WARN: Could not get active application: %s" % e)
|
|
|
|
# Search for first application if no active
|
|
if not target_app:
|
|
try:
|
|
all_children = primary_project.get_children(True)
|
|
for child in all_children:
|
|
if hasattr(child, 'is_application') and child.is_application:
|
|
target_app = child
|
|
app_name = getattr(child, 'get_name', lambda: "Unnamed App")()
|
|
break
|
|
except Exception as e:
|
|
print("WARN: Error finding application: %s" % e)
|
|
|
|
if not target_app:
|
|
raise RuntimeError("No application found in project.")
|
|
|
|
print("DEBUG: Using application: %s" % app_name)
|
|
|
|
# Try to get or create online application
|
|
online_app = None
|
|
|
|
# Pattern 1: app.create_online_application()
|
|
if hasattr(target_app, 'create_online_application'):
|
|
try:
|
|
online_app = target_app.create_online_application()
|
|
if online_app:
|
|
print("DEBUG: Created online application via app.create_online_application()")
|
|
return online_app, target_app
|
|
except Exception as e:
|
|
print("DEBUG: app.create_online_application() failed: %s" % e)
|
|
|
|
# Pattern 2: scriptengine online module
|
|
try:
|
|
import scriptengine as se
|
|
if hasattr(se, 'online'):
|
|
online_module = se.online
|
|
if hasattr(online_module, 'create_online_application'):
|
|
try:
|
|
online_app = online_module.create_online_application(target_app)
|
|
if online_app:
|
|
print("DEBUG: Created online application via scriptengine.online.create_online_application()")
|
|
return online_app, target_app
|
|
except Exception as e:
|
|
print("DEBUG: scriptengine.online.create_online_application() failed: %s" % e)
|
|
except Exception as e:
|
|
print("DEBUG: scriptengine online module access failed: %s" % e)
|
|
|
|
# Pattern 3: Check if there's already an online_application property
|
|
if hasattr(target_app, 'online_application'):
|
|
try:
|
|
online_app = target_app.online_application
|
|
if online_app:
|
|
print("DEBUG: Found existing online application via app.online_application")
|
|
return online_app, target_app
|
|
except Exception as e:
|
|
print("DEBUG: app.online_application failed: %s" % e)
|
|
|
|
if not online_app:
|
|
raise RuntimeError("Could not create online application connection. Ensure a device/gateway is configured in the project.")
|
|
|
|
return online_app, target_app
|
|
# --- End of ensure_online_connection function ---
|
|
|
|
|
|
# --- Function to ensure the online application is logged in ---
|
|
def ensure_logged_in(online_app, login_wait_seconds=30):
|
|
"""Idempotently log into the device. In persistent mode the login state
|
|
survives across calls and this is a no-op via online_app.is_logged_in.
|
|
In headless mode each tool call spawns a fresh CODESYS process, so any
|
|
online tool (start_stop, read_variable, write_variable,
|
|
read_running_version_online) needs to log in itself before its action.
|
|
|
|
Without this helper, tools other than connect_to_device + download_to_device
|
|
fail in headless mode with 'Application not logged in.' (start/stop) or
|
|
'Invalid expression' (read/write).
|
|
|
|
Mirrors the SP-version-drift login probe in connect_to_device.py:
|
|
OnlineChangeOption (older) vs LoginMode (SP21+) vs members on the
|
|
online_app object itself; (mode, force_download_bool) two-arg shape vs
|
|
(mode,) one-arg shape vs no-arg fallback. Prefers least-invasive
|
|
'TryOnlineChange' / 'OnlineChangeOnly' semantics."""
|
|
import scriptengine as _se # local import; ensure_online_connection.py
|
|
# is concatenated into the script body so
|
|
# the top-level import in the main script
|
|
# is also visible, but keep this defensive.
|
|
# Short-circuit: already logged in (persistent mode).
|
|
if hasattr(online_app, 'is_logged_in'):
|
|
try:
|
|
if online_app.is_logged_in:
|
|
print("DEBUG: ensure_logged_in: already logged in (persistent session).")
|
|
return
|
|
except Exception as e:
|
|
print("DEBUG: ensure_logged_in: is_logged_in property raised: %s" % e)
|
|
|
|
if not hasattr(online_app, 'login'):
|
|
raise TypeError("Online application does not support login().")
|
|
|
|
# Build enum candidate list -- same logic as connect_to_device.py's
|
|
# main body, kept here so headless tool calls don't have to
|
|
# re-implement it. Probe both script_engine.* and online_app.* for
|
|
# LoginMode / OnlineChangeOption.
|
|
enum_sources = []
|
|
for src_name in ('LoginMode', 'OnlineChangeOption'):
|
|
if hasattr(_se, src_name):
|
|
try:
|
|
enum_sources.append((src_name, getattr(_se, src_name)))
|
|
except Exception:
|
|
pass
|
|
for src_name in ('LoginMode', 'OnlineChangeOption'):
|
|
if hasattr(online_app, src_name):
|
|
try:
|
|
enum_sources.append(('online_app.' + src_name, getattr(online_app, src_name)))
|
|
except Exception:
|
|
pass
|
|
|
|
preferred_order = ('TryOnlineChange', 'OnlineChangeOnly', 'Try', 'OnlineChange',
|
|
'WithDownload', 'ForceDownload', 'Download',
|
|
'None_', 'None')
|
|
enum_candidates = []
|
|
seen_keys = set()
|
|
for src_name, oc in enum_sources:
|
|
try:
|
|
members = sorted([m for m in dir(oc) if not m.startswith('_')])
|
|
except Exception:
|
|
members = []
|
|
for preferred in preferred_order:
|
|
if preferred in members:
|
|
key = '%s.%s' % (src_name, preferred)
|
|
if key not in seen_keys:
|
|
try:
|
|
enum_candidates.append((key, getattr(oc, preferred)))
|
|
seen_keys.add(key)
|
|
except Exception:
|
|
pass
|
|
for m in members:
|
|
key = '%s.%s' % (src_name, m)
|
|
if key not in seen_keys:
|
|
try:
|
|
enum_candidates.append((key, getattr(oc, m)))
|
|
seen_keys.add(key)
|
|
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: ensure_logged_in: %s succeeded" % desc)
|
|
logged_in = True
|
|
break
|
|
except Exception as e:
|
|
last_err = e
|
|
|
|
if not logged_in:
|
|
raise RuntimeError("ensure_logged_in: all login() call shapes failed. Last error: %s" % last_err)
|
|
|
|
# Settle wait. Same STABLE_STATES as connect_to_device.
|
|
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: ensure_logged_in: state stabilised at '%s' after %ds" % (state, elapsed))
|
|
return
|
|
try:
|
|
_se.system.delay(1000)
|
|
except Exception:
|
|
pass
|
|
print("DEBUG: ensure_logged_in: state did not stabilise within %ds (last='%s'); proceeding anyway." % (login_wait_seconds, state))
|
|
# --- End of ensure_logged_in function ---
|