Files
Codesys-MCP/python_scripting/windows_codesys_agent/scripts/sync_project.py

315 lines
10 KiB
Python

#!/usr/bin/env python
"""Batch-push local source changes into a CODESYS project.
Receives a list of textual updates and IO mapping changes in a single payload,
applies them all in one project open/save cycle.
"""
from __future__ import print_function
import os
from _common import emit, load_payload
def _discover_project_file(path_value):
if os.path.isfile(path_value):
lower_path = path_value.lower()
if lower_path.endswith(".project") or lower_path.endswith(".projectarchive"):
return path_value
raise RuntimeError("project_path must be .project or .projectarchive: {0}".format(path_value))
if os.path.isdir(path_value):
candidates = []
for entry in os.listdir(path_value):
full_path = os.path.join(path_value, entry)
if os.path.isfile(full_path):
lower_name = entry.lower()
if lower_name.endswith(".project") or lower_name.endswith(".projectarchive"):
candidates.append(full_path)
if len(candidates) == 1:
return candidates[0]
if not candidates:
raise RuntimeError("No .project/.projectarchive in: {0}".format(path_value))
raise RuntimeError("Multiple project files: {0}".format(", ".join(candidates)))
raise RuntimeError("Path does not exist: {0}".format(path_value))
def _safe_str(val):
try:
return "{0}".format(val)
except Exception:
return "?"
def _name_of(obj):
try:
return obj.get_name(False)
except Exception:
try:
return obj.get_name()
except Exception:
return _safe_str(obj)
def _children_of(obj):
try:
return list(obj.get_children(False))
except Exception:
try:
return list(obj.get_children())
except Exception:
return []
def _find_by_path(root, path_str):
parts = [p.strip() for p in path_str.split("/") if p.strip()]
current = root
for i, part in enumerate(parts):
found = None
for child in _children_of(current):
if _name_of(child) == part:
found = child
break
if found is None:
return None
current = found
return current
def _set_text(doc, new_text):
try:
doc.replace(new_text=new_text)
except TypeError:
length = doc.length
if length > 0:
doc.remove(offset=0, length=length)
if new_text:
doc.append(new_text)
def _apply_textual_updates(proj, updates, results, warnings):
for upd in updates:
obj_path = upd.get("object_path", "")
obj_name = upd.get("object_name", "")
decl = upd.get("declaration")
impl = upd.get("implementation")
target_label = obj_path or obj_name
if not target_label:
warnings.append("skipped update: no object_path or object_name")
continue
try:
obj = None
if obj_path:
obj = _find_by_path(proj, obj_path)
if obj is None and obj_name:
found = proj.find(obj_name, True)
if found:
obj = found[0]
if obj is None:
warnings.append("not_found: {0}".format(target_label))
continue
name = _name_of(obj)
entry = {"name": name, "path": target_label, "declaration_updated": False, "implementation_updated": False}
if decl is not None and getattr(obj, "has_textual_declaration", False):
_set_text(obj.textual_declaration, decl)
entry["declaration_updated"] = True
if impl is not None and getattr(obj, "has_textual_implementation", False):
_set_text(obj.textual_implementation, impl)
entry["implementation_updated"] = True
results.append(entry)
except Exception as exc:
warnings.append("textual_update_failed({0}): {1}".format(target_label, exc))
def _apply_io_mappings(proj, mappings, results, warnings):
for m in mappings:
device_path = m.get("device_path", "")
param_id = m.get("param_id")
param_name = m.get("param_name")
variable = m.get("variable", "")
if not device_path or not variable:
warnings.append("io_mapping skipped: missing device_path or variable")
continue
try:
device = _find_by_path(proj, device_path)
if device is None:
warnings.append("device not found: {0}".format(device_path))
continue
target_param = None
try:
params = device.device_parameters
for p in list(params):
if param_id is not None and int(p.id) == int(param_id):
target_param = p
break
if param_name and _safe_str(getattr(p, "visible_name", "")) == param_name:
target_param = p
break
except Exception:
pass
if target_param is None:
for conn in list(device.connectors):
try:
hp = conn.host_parameters
for p in list(hp):
if param_id is not None and int(p.id) == int(param_id):
target_param = p
break
if param_name and _safe_str(getattr(p, "visible_name", "")) == param_name:
target_param = p
break
except Exception:
pass
if target_param:
break
if target_param is None:
warnings.append("param not found: id={0} name={1} on {2}".format(param_id, param_name, device_path))
continue
iom = target_param.io_mapping
if iom is None:
warnings.append("no io_mapping for param on {0}".format(device_path))
continue
old_var = _safe_str(iom.variable) if iom.variable else ""
iom.variable = variable
results.append({
"type": "io_mapping",
"device_path": device_path,
"param_id": int(target_param.id),
"old_variable": old_var,
"new_variable": variable,
"status": "mapped",
})
except Exception as exc:
warnings.append("io_mapping_failed({0}): {1}".format(device_path, exc))
def _apply_device_params(proj, params, results, warnings):
for dp in params:
device_path = dp.get("device_path", "")
param_id = dp.get("param_id")
param_name = dp.get("param_name", "")
value = dp.get("value")
if not device_path or value is None:
warnings.append("device_param skipped: missing device_path or value")
continue
try:
device = _find_by_path(proj, device_path)
if device is None:
warnings.append("device not found: {0}".format(device_path))
continue
target = None
for p in list(device.device_parameters):
if param_id is not None and int(p.id) == int(param_id):
target = p
break
if param_name and _safe_str(getattr(p, "visible_name", "")) == param_name:
target = p
break
if target is None:
warnings.append("param not found on {0}".format(device_path))
continue
old_val = _safe_str(target.value) if hasattr(target, "value") else "?"
target.value = _safe_str(value)
results.append({
"type": "device_param",
"device_path": device_path,
"param_id": int(target.id),
"old_value": old_val,
"new_value": _safe_str(value),
"status": "set",
})
except Exception as exc:
warnings.append("device_param_failed({0}): {1}".format(device_path, exc))
def main():
payload = load_payload()
project_path = payload.get("project_path")
if not project_path:
raise ValueError("project_path is required.")
dry_run = bool(payload.get("dry_run", False))
textual_updates = payload.get("textual_updates", [])
io_mappings = payload.get("io_mappings", [])
device_params = payload.get("device_params", [])
if not textual_updates and not io_mappings and not device_params:
raise ValueError("Nothing to sync. Provide textual_updates, io_mappings, or device_params.")
pp = os.path.abspath(os.path.expanduser(project_path))
pp = _discover_project_file(pp)
if "projects" not in globals():
raise RuntimeError("Run through CODESYS ScriptEngine (--runscript).")
if projects.primary:
projects.primary.close()
proj = projects.open(pp, primary=True)
results = []
warnings = []
if dry_run:
emit({
"ok": True,
"action": "sync_project",
"project_path": pp,
"dry_run": True,
"textual_update_count": len(textual_updates),
"io_mapping_count": len(io_mappings),
"device_param_count": len(device_params),
"message": "Dry-run: would apply {0} textual updates, {1} IO mappings, {2} device params.".format(
len(textual_updates), len(io_mappings), len(device_params)),
"warnings": [],
})
proj.close()
return
if textual_updates:
_apply_textual_updates(proj, textual_updates, results, warnings)
if io_mappings:
_apply_io_mappings(proj, io_mappings, results, warnings)
if device_params:
_apply_device_params(proj, device_params, results, warnings)
try:
proj.save()
except Exception as exc:
warnings.append("project_save_failed: {0}".format(exc))
proj.close()
emit({
"ok": True,
"action": "sync_project",
"project_path": pp,
"dry_run": False,
"results": results,
"result_count": len(results),
"warnings": warnings,
})
if __name__ == "__main__":
main()