0
0
Fork 0

fix(online tools): auto-login + non-CONSTANT version GVL

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.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-26 19:54:13 +02:00
parent d414c779a5
commit ef259c8ee3
7 changed files with 173 additions and 10 deletions

View file

@ -27,9 +27,15 @@ VALID_LEVELS = ('major', 'minor', 'revision', 'build')
# tool can pull it via online connect + read_variable. Kept as
# qualified_only so it can't accidentally shadow a same-named local.
VERSION_GVL_NAME = '_MCP_PROJECT_VERSION'
# NOT VAR_GLOBAL CONSTANT: CODESYS inlines CONSTANT scalars at compile time,
# which strips them from the online symbol table -- read_running_version_online
# would then fail with 'Invalid expression' on every project bumped via this
# tool. Plain VAR_GLOBAL keeps the symbol live so the online tool can read
# the running version. The string is still effectively read-only at runtime
# (only bump_project_version updates it via textual_declaration.replace).
VERSION_GVL_DECLARATION_TEMPLATE = (
"{attribute 'qualified_only'}\n"
"VAR_GLOBAL CONSTANT\n"
"VAR_GLOBAL\n"
" sVersion : STRING := '%s';\n"
"END_VAR\n"
)

View file

@ -77,3 +77,125 @@ def ensure_online_connection(primary_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 ---

View file

@ -23,6 +23,8 @@ try:
online_app, target_app = ensure_online_connection(primary_project)
app_name = getattr(target_app, 'get_name', lambda: 'Unknown')()
print("DEBUG: connected to application '%s'" % app_name)
# Auto-login -- idempotent in persistent mode, required in headless.
ensure_logged_in(online_app)
# Read the version anchor
raw_value = None
@ -36,7 +38,24 @@ try:
raw_value = result
except Exception as e:
msg = str(e)
if 'not found' in msg.lower() or 'unknown' in msg.lower() or 'symbol' in msg.lower():
msg_l = msg.lower()
if 'invalid expression' in msg_l:
# Most common cause: the GVL is declared VAR_GLOBAL CONSTANT,
# which CODESYS inlines at compile time -- the symbol never
# makes it into the online table. This is a known footgun
# because older bump_project_version emitted CONSTANT GVLs.
raise RuntimeError(
"Online evaluator returned 'Invalid expression' for '%s'. "
"Most likely cause: the _MCP_PROJECT_VERSION GVL was created "
"with VAR_GLOBAL CONSTANT, which CODESYS inlines at compile "
"time so the symbol is not in the online symbol table. "
"Fix: edit _MCP_PROJECT_VERSION's declaration to drop "
"CONSTANT (just VAR_GLOBAL), then bump_project_version + "
"download_to_device. (newer bump_project_version emits "
"non-CONSTANT GVLs by default, so future-bumped projects "
"are unaffected.) Underlying error: %s" % (VARIABLE_PATH, e)
)
if 'not found' in msg_l or 'unknown' in msg_l or 'symbol' in msg_l:
raise RuntimeError(
"Variable '%s' not found on the running PLC. "
"Either bump_project_version has never been run on this project "

View file

@ -9,6 +9,8 @@ try:
online_app, target_app = ensure_online_connection(primary_project)
app_name = getattr(target_app, 'get_name', lambda: "Unknown")()
# Auto-login -- idempotent in persistent mode, required in headless.
ensure_logged_in(online_app)
# Read the variable value
value = None

View file

@ -13,6 +13,10 @@ try:
online_app, target_app = ensure_online_connection(primary_project)
app_name = getattr(target_app, 'get_name', lambda: "Unknown")()
# In headless mode each MCP call spawns a fresh CODESYS process, so the
# login from a prior connect_to_device call is gone. ensure_logged_in
# is idempotent in persistent mode (short-circuits via is_logged_in).
ensure_logged_in(online_app)
if action_lower == 'start':
if hasattr(online_app, 'start'):

View file

@ -12,6 +12,8 @@ try:
online_app, target_app = ensure_online_connection(primary_project)
app_name = getattr(target_app, 'get_name', lambda: "Unknown")()
# Auto-login -- idempotent in persistent mode, required in headless.
ensure_logged_in(online_app)
# SP21+/SP22 uses a two-step prepare-then-write pattern:
# 1) set_prepared_value(name, value) -- stage the value

View file

@ -109,16 +109,24 @@ Status legend: **✅ working** • **⚠ degraded** (works but with known gotcha
These all require a running PLC and a configured device gateway. Persistent timing here is **gateway-bound**, not CODESYS-bound — it's network roundtrips, not script overhead.
**v5 sweep verified end-to-end against local CODESYS Control Win V3** (PLATEA hostname, gateway port 11740) on 2026-04-26. All 8 tools called in sequence against MCPTest2 v1.3.4.0. **7/8 PASS, 1 broken-by-design (root-caused + fixed in this sweep, see notes).** Use **persistent mode** for any device-tool chain — headless spawns a fresh CODESYS process per call which kills the login state established by `connect_to_device`. Auto-login was added to all four scripts that previously relied on persisted login (start_stop_application, read_variable, write_variable, read_running_version_online) so they now also work end-to-end in headless. See "Headless mode + device tools" deep-dive below.
| Tool | Status | What it does | Persistent (with PLC) | Headless |
|---|---|---|---|---|
| `connect_to_device` | ✅ (fixed [`2607063`](https://github.com/phobicdotno/Codesys-MCP/commit/2607063), needs PLC to verify) | Logs into the active application via `online_app.login(...)`. Now probes 4 enum source locations (`script_engine.LoginMode`, `script_engine.OnlineChangeOption`, `online_app.LoginMode`, `online_app.OnlineChangeOption`) plus 3-arg call shape variant. | 15 s | n/a |
| `disconnect_from_device` | ✅ (when connected) | `online_app.logout()` | 200500 ms | n/a (no persistent online context) |
| `get_application_state` | ✅ | Reads `online_app.application_state` (run/stop/halt/connected/...) | 100300 ms (when online); 100 ms when offline | 814 s |
| `read_variable` | ✅ (when connected) | `online_app.read_value('var.path')` over the gateway | 100500 ms per call | n/a |
| `write_variable` | ✅ (when connected) | `online_app.write_value('var.path', value)` | 100500 ms | n/a |
| `download_to_device` | ✅ (when connected) | Pushes the new boot application after a code change. Heavy. | 560 s (project size dependent) | n/a |
| `start_stop_application` | ✅ (when connected) | `online_app.start()` / `.stop()` | 200500 ms | n/a |
| `read_running_version_online` | ✅ (when connected) | Reads `_MCP_PROJECT_VERSION.sVersion` from the running PLC | 100500 ms | n/a |
| `connect_to_device` | ✅ verified end-to-end (v5 sweep, fixed [`2607063`](https://github.com/phobicdotno/Codesys-MCP/commit/2607063)) | Logs into the active application via `online_app.login(...)`. Probes 4 enum source locations (`script_engine.LoginMode`, `script_engine.OnlineChangeOption`, `online_app.LoginMode`, `online_app.OnlineChangeOption`) plus 3-arg call shape variant. | 15 s | runs but login state lost on next call (use auto-login helper) |
| `disconnect_from_device` | ✅ verified end-to-end (v5: `Logged In: True``False` confirmed) | `online_app.logout()` | 200500 ms | n/a (no persistent online context) |
| `get_application_state` | ✅ verified end-to-end (v5: returns `run`/`stop` correctly) | Reads `online_app.application_state` and `is_logged_in`. | 100300 ms (when online); 100 ms when offline | 814 s (returns `none, Logged In: False` if not connected) |
| `read_variable` | ✅ verified end-to-end (v5: `PLC_PRG.watchdog1 = BYTE#225` live, ticking) | `online_app.read_value('var.path')` over the gateway. **CONSTANT VAR_GLOBAL scalars are inlined at compile time and absent from the online symbol table -- expect 'Invalid expression'.** | 100500 ms per call | now auto-logs-in (v5 fix) |
| `write_variable` | ✅ verified end-to-end (v5: wrote 200 → read 204 4s later, 1 Hz tick proves write took) | `set_prepared_value` + `write_prepared_values` (SP21+ path), falls back to `write_value` / `set_value` / `write` / `set` for older SPs | 100500 ms | now auto-logs-in (v5 fix) |
| `download_to_device` | ✅ verified end-to-end (v5: pushed v1.3.4.0 to PLATEA) | Pushes the new boot application after a code change. Heavy. Has its own login probe (independent from auto-login helper). | 560 s (project size dependent) | runs end-to-end |
| `start_stop_application` | ✅ verified end-to-end (v5: stop → `stop` state, start → `run` state) | `online_app.start()` / `.stop()` | 200500 ms | now auto-logs-in (v5 fix) |
| `read_running_version_online` | ⚠ **broken-by-design pre-v5; fixed in this sweep** | Reads `_MCP_PROJECT_VERSION.sVersion` from the running PLC. **Old `bump_project_version` emitted the GVL as `VAR_GLOBAL CONSTANT`, which CODESYS inlines at compile time, so the symbol never reaches the online evaluator -- read returns `Invalid expression`.** Fixed in v5 by dropping `CONSTANT` from `VERSION_GVL_DECLARATION_TEMPLATE`. Existing projects auto-migrate on next bump (the existing-GVL branch overwrites `textual_declaration` with the new template). Script also detects the failure mode and emits a precise actionable error. | 100500 ms | now auto-logs-in (v5 fix) |
#### Headless mode + device tools (v5 deep-dive)
In **persistent** mode the MCP keeps one CODESYS process alive; `connect_to_device`'s login state survives across calls. In **headless** mode each MCP call spawns a fresh `CODESYS.exe --noUI` process — login state from the prior call is gone before the next call starts. Pre-v5, only `connect_to_device` and `download_to_device` did their own `online_app.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).
**v5 fix:** `ensure_logged_in(online_app, login_wait_seconds=30)` was added to `ensure_online_connection.py` next to the existing `ensure_online_connection`. The four affected scripts now call it immediately after creating the online app. The helper short-circuits via `online_app.is_logged_in` so persistent mode is a no-op (no extra login roundtrip), then runs the same enum-probe + call-shape probe + STABLE_STATES settle-wait pattern that `connect_to_device` and `download_to_device` already use. Net effect: every online tool now works end-to-end in BOTH modes.
### Git wrappers (6)