diff --git a/src/scripts/bump_project_version.py b/src/scripts/bump_project_version.py index 939d4b9..c8c02f8 100644 --- a/src/scripts/bump_project_version.py +++ b/src/scripts/bump_project_version.py @@ -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" ) diff --git a/src/scripts/ensure_online_connection.py b/src/scripts/ensure_online_connection.py index cc95aa9..d8fde69 100644 --- a/src/scripts/ensure_online_connection.py +++ b/src/scripts/ensure_online_connection.py @@ -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 --- diff --git a/src/scripts/read_running_version_online.py b/src/scripts/read_running_version_online.py index c05e47b..2e0d269 100644 --- a/src/scripts/read_running_version_online.py +++ b/src/scripts/read_running_version_online.py @@ -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 " diff --git a/src/scripts/read_variable.py b/src/scripts/read_variable.py index a47b849..795496d 100644 --- a/src/scripts/read_variable.py +++ b/src/scripts/read_variable.py @@ -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 diff --git a/src/scripts/start_stop_application.py b/src/scripts/start_stop_application.py index 9e893ff..d40bbed 100644 --- a/src/scripts/start_stop_application.py +++ b/src/scripts/start_stop_application.py @@ -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'): diff --git a/src/scripts/write_variable.py b/src/scripts/write_variable.py index f014bf1..bc85a39 100644 --- a/src/scripts/write_variable.py +++ b/src/scripts/write_variable.py @@ -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 diff --git a/tests/TEST_OVERVIEW.md b/tests/TEST_OVERVIEW.md index e7f3d92..251839e 100644 --- a/tests/TEST_OVERVIEW.md +++ b/tests/TEST_OVERVIEW.md @@ -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. | 1–5 s | n/a | -| `disconnect_from_device` | ✅ (when connected) | `online_app.logout()` | 200–500 ms | n/a (no persistent online context) | -| `get_application_state` | ✅ | Reads `online_app.application_state` (run/stop/halt/connected/...) | 100–300 ms (when online); 100 ms when offline | 8–14 s | -| `read_variable` | ✅ (when connected) | `online_app.read_value('var.path')` over the gateway | 100–500 ms per call | n/a | -| `write_variable` | ✅ (when connected) | `online_app.write_value('var.path', value)` | 100–500 ms | n/a | -| `download_to_device` | ✅ (when connected) | Pushes the new boot application after a code change. Heavy. | 5–60 s (project size dependent) | n/a | -| `start_stop_application` | ✅ (when connected) | `online_app.start()` / `.stop()` | 200–500 ms | n/a | -| `read_running_version_online` | ✅ (when connected) | Reads `_MCP_PROJECT_VERSION.sVersion` from the running PLC | 100–500 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. | 1–5 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()` | 200–500 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`. | 100–300 ms (when online); 100 ms when offline | 8–14 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'.** | 100–500 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 | 100–500 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). | 5–60 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()` | 200–500 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. | 100–500 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)