#!/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()