Three fixes found while running an NVL-over-UDP benchmark on Linux-SL (x64) and
WAGO 750-8216 PFC200 (ARM) targets:
- _find_gateway (scan/rebind/verify): when the device gateway GUID is absent from
scriptengine.online.gateways (fresh/headless profile), auto-register one under that
GUID from env vars (CODESYS_GATEWAY_ADDR / _PORT / _NAME, default port 1217). No
hard-coded IPs; unchanged behaviour when the env var is unset.
- add_library: gate the post-add hollow-check backout on `resolved_lib is None` so a
genuine managed add (whose effective_resolution only fills after reopen) is not
discarded as unresolvable ("Project NOT saved").
- remove_library: verify removal by EXACT name, not prefix match, so a same-base-name
sibling (e.g. "X" vs "X, * (System)") no longer triggers a false "still present".
Also docs/NVL_BENCH_FINDINGS.md: the fixes, proposed tools (reload_library,
swap_device, close_project, set_task_interval, bootstrap_device_user) and SysSocket
SP21 signatures captured during the bench.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
6.1 KiB
Python
155 lines
6.1 KiB
Python
# rebind_device_to_scan: re-bind the configured device to a scan result
|
|
# (typically the same physical PLC at a new gateway address after a
|
|
# reboot / re-cable / DHCP change).
|
|
#
|
|
# Match rule (in priority order):
|
|
# 1. exact device_name match (case-insensitive)
|
|
# 2. exact device_id match
|
|
# 3. caller-supplied address override (MATCH_ADDRESS)
|
|
# 4. if exactly one candidate, pick it
|
|
# 5. otherwise: refuse and dump the candidate list
|
|
#
|
|
# Once matched, call device.set_gateway_and_address(gateway, address) and
|
|
# save the project so the rebind persists across CODESYS restarts.
|
|
|
|
import sys, scriptengine as script_engine, os, traceback, json
|
|
|
|
PROJECT_FILE_PATH = r"{PROJECT_FILE_PATH}"
|
|
MATCH_NAME = r"{MATCH_NAME}" # optional override
|
|
MATCH_DEVICE_ID = r"{MATCH_DEVICE_ID}" # optional override
|
|
MATCH_ADDRESS = r"{MATCH_ADDRESS}" # optional override -- skip scan match
|
|
|
|
|
|
def _find_gateway(target_device):
|
|
target_guid = str(target_device.get_gateway())
|
|
online = getattr(script_engine, 'online', None)
|
|
if online is None:
|
|
raise RuntimeError("scriptengine.online is not available.")
|
|
for gw in online.gateways:
|
|
try:
|
|
if str(gw.guid) == target_guid:
|
|
return gw
|
|
except Exception:
|
|
continue
|
|
# NVL-bench fix: when the device gateway GUID is not configured in this IDE profile
|
|
# (fresh/headless profile), auto-register one under that GUID from env vars so online
|
|
# tools work without manually picking a gateway in the IDE dropdown. Address/port are
|
|
# NOT hard-coded -- set CODESYS_GATEWAY_ADDR (and optionally _PORT/_NAME).
|
|
try:
|
|
import os as _os
|
|
_addr = _os.environ.get("CODESYS_GATEWAY_ADDR")
|
|
if _addr:
|
|
import System as _Sys
|
|
_port = int(_os.environ.get("CODESYS_GATEWAY_PORT", "1217"))
|
|
_gw = online.gateways.add_new_gateway(
|
|
_os.environ.get("CODESYS_GATEWAY_NAME", "mcp-gateway"),
|
|
{0: _addr, 1: _port}, None, _Sys.Guid(target_guid))
|
|
print("DEBUG: auto-registered gateway %s:%d under guid %s" % (_addr, _port, target_guid))
|
|
return _gw
|
|
except Exception as _e:
|
|
print("DEBUG: gateway auto-registration failed: %s" % _e)
|
|
raise RuntimeError("Device's gateway Guid %s is not in scriptengine.online.gateways." % target_guid)
|
|
|
|
|
|
def _describe(t):
|
|
fields = ("device_name", "type_name", "vendor_name", "address", "parent_address")
|
|
out = {}
|
|
for f in fields:
|
|
try:
|
|
v = getattr(t, f, None)
|
|
out[f] = "" if v is None else str(v)
|
|
except Exception:
|
|
out[f] = ""
|
|
try:
|
|
did = getattr(t, 'device_id', None)
|
|
out["device_id"] = "" if did is None else str(did)
|
|
except Exception:
|
|
out["device_id"] = ""
|
|
return out
|
|
|
|
|
|
def _pick_candidate(items, want_name, want_id, want_address):
|
|
# 1. caller forced an address -- no scan match needed
|
|
if want_address:
|
|
return {"address": want_address, "device_name": "(forced)"}, "forced-address"
|
|
# 2. by name
|
|
if want_name:
|
|
wn = want_name.lower()
|
|
hits = [i for i in items if i.get('device_name', '').lower() == wn]
|
|
if len(hits) == 1:
|
|
return hits[0], "by-name"
|
|
if len(hits) > 1:
|
|
return None, "ambiguous-name"
|
|
# 3. by device_id
|
|
if want_id:
|
|
hits = [i for i in items if i.get('device_id') == want_id]
|
|
if len(hits) == 1:
|
|
return hits[0], "by-device-id"
|
|
if len(hits) > 1:
|
|
return None, "ambiguous-device-id"
|
|
# 4. single candidate
|
|
if len(items) == 1:
|
|
return items[0], "only-candidate"
|
|
return None, "no-match"
|
|
|
|
|
|
try:
|
|
print("DEBUG: rebind_device_to_scan: Project='%s' name='%s' id='%s' addr='%s'" % (
|
|
PROJECT_FILE_PATH, MATCH_NAME, MATCH_DEVICE_ID, MATCH_ADDRESS))
|
|
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
|
target = find_target_device(primary_project)
|
|
target_name = target.get_name() if hasattr(target, 'get_name') else ''
|
|
cached_address = str(target.get_address())
|
|
print("DEBUG: target='%s' cached_address='%s'" % (target_name, cached_address))
|
|
|
|
gw = _find_gateway(target)
|
|
items = []
|
|
if not MATCH_ADDRESS:
|
|
print("DEBUG: scanning gateway '%s'..." % gw.name)
|
|
items = [_describe(t) for t in (gw.perform_network_scan() or [])]
|
|
|
|
pick, reason = _pick_candidate(items, MATCH_NAME, MATCH_DEVICE_ID, MATCH_ADDRESS)
|
|
|
|
if pick is None:
|
|
print("### REBIND_RESULT_START ###")
|
|
print(json.dumps({
|
|
"rebound": False,
|
|
"reason": reason,
|
|
"cached_address": cached_address,
|
|
"candidates": items,
|
|
"candidate_count": len(items),
|
|
}, sort_keys=True))
|
|
print("### REBIND_RESULT_END ###")
|
|
print("SCRIPT_SUCCESS: rebind_device_to_scan completed (no rebind).")
|
|
sys.exit(0)
|
|
|
|
new_address = pick.get('address') or ''
|
|
if not new_address:
|
|
raise RuntimeError("Selected candidate has empty address: %r" % pick)
|
|
|
|
# Always call set_gateway_and_address, even when the address didn't
|
|
# change. The IDE's Select-Device + OK flow does the same -- it
|
|
# re-applies the binding to refresh the device's scanned_* properties
|
|
# and re-establish a live session, which login()/download() depend
|
|
# on. Skipping when address matches leaves the binding stale.
|
|
print("DEBUG: rebinding (cached='%s' new='%s' reason=%s)" % (cached_address, new_address, reason))
|
|
target.set_gateway_and_address(gw, new_address)
|
|
try:
|
|
primary_project.save()
|
|
except Exception as e:
|
|
print("WARN: project.save() raised: %s -- rebind applied in-memory; flush manually." % e)
|
|
|
|
print("### REBIND_RESULT_START ###")
|
|
print(json.dumps({
|
|
"rebound": True,
|
|
"reason": reason,
|
|
"old_address": cached_address,
|
|
"new_address": new_address,
|
|
"matched_candidate": pick,
|
|
}, sort_keys=True))
|
|
print("### REBIND_RESULT_END ###")
|
|
print("SCRIPT_SUCCESS: rebind_device_to_scan completed.")
|
|
except Exception as e:
|
|
print("SCRIPT_ERROR: %s: %s" % (type(e).__name__, e))
|
|
traceback.print_exc()
|
|
sys.exit(1)
|