Update .gitignore and README.md for CODESYS MCP project. Added entries to .gitignore for VSIX files and VS Code settings. Expanded README to include architecture, components, deployment instructions, and details about the Cursor extension.
This commit is contained in:
57
python_scripting/windows_codesys_agent/README.md
Normal file
57
python_scripting/windows_codesys_agent/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Windows CODESYS Agent
|
||||
|
||||
This service runs inside the Windows VM that has CODESYS installed.
|
||||
|
||||
It receives authenticated job requests from the Linux MCP bridge and executes action scripts locally.
|
||||
|
||||
## Supported actions
|
||||
|
||||
- `create_project`
|
||||
- `read_project_inventory`
|
||||
- `stage_existing_project`
|
||||
- `write_pou`
|
||||
- `build_project`
|
||||
- `download_to_device`
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /health`
|
||||
- `POST /jobs`
|
||||
- `GET /jobs/{job_id}`
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `CODESYS_AGENT_TOKEN` (required shared secret)
|
||||
- `CODESYS_AGENT_SCRIPTS_DIR` (default: `C:\\codesys-agent\\scripts`)
|
||||
- `CODESYS_AGENT_RUNTIME_DIR` (default: `C:\\codesys-agent\\runtime`)
|
||||
- `CODESYS_AGENT_PYTHON_BIN` (default: `python`)
|
||||
- `CODESYS_AGENT_TIMEOUT_SEC` (default: `180`)
|
||||
- `CODESYS_AGENT_ALLOW_DOWNLOAD` (`true` or `false`, default `false`)
|
||||
- `CODESYS_EXE_PATH` (required for ScriptEngine actions, e.g. `C:\\Program Files\\CODESYS 3.5.xx\\CODESYS\\Common\\CODESYS.exe`)
|
||||
- `CODESYS_PROFILE` (required for ScriptEngine actions, e.g. `CODESYS V3.5 SP21`)
|
||||
- `CODESYS_NO_UI` (`true`/`false`, default `true`, runs actions without opening visible IDE UI)
|
||||
- `CODESYS_SCRIPT_TIMEOUT_SEC` (default `600`)
|
||||
|
||||
## Run on Windows VM
|
||||
|
||||
```powershell
|
||||
cd windows_codesys_agent
|
||||
py -m venv .venv
|
||||
.venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
$env:CODESYS_AGENT_TOKEN = "<shared-secret-token>"
|
||||
$env:CODESYS_AGENT_SCRIPTS_DIR = "C:\codesys-agent\windows_codesys_agent\scripts"
|
||||
$env:CODESYS_AGENT_RUNTIME_DIR = "C:\codesys-agent\runtime"
|
||||
$env:CODESYS_AGENT_ALLOW_DOWNLOAD = "false"
|
||||
$env:CODESYS_EXE_PATH = "C:\Program Files\CODESYS 3.5.21.0\CODESYS\Common\CODESYS.exe"
|
||||
$env:CODESYS_PROFILE = "CODESYS V3.5 SP21"
|
||||
$env:CODESYS_NO_UI = "true"
|
||||
uvicorn agent:app --host 0.0.0.0 --port 8787
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Keep VM firewall limited to trusted source IPs.
|
||||
- Use a long random token, rotate periodically.
|
||||
- Keep `CODESYS_AGENT_ALLOW_DOWNLOAD=false` until you finish dry-run validation.
|
||||
- For full automation, verify `CODESYS_EXE_PATH` and `CODESYS_PROFILE` are correct for your VM installation.
|
||||
301
python_scripting/windows_codesys_agent/agent.py
Normal file
301
python_scripting/windows_codesys_agent/agent.py
Normal file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Windows-side CODESYS agent with async job processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
API_TOKEN = os.environ.get("CODESYS_AGENT_TOKEN", "")
|
||||
SCRIPTS_DIR = Path(
|
||||
os.environ.get("CODESYS_AGENT_SCRIPTS_DIR", r"C:\codesys-agent\scripts")
|
||||
).resolve()
|
||||
RUNTIME_DIR = Path(
|
||||
os.environ.get("CODESYS_AGENT_RUNTIME_DIR", r"C:\codesys-agent\runtime")
|
||||
).resolve()
|
||||
PYTHON_BIN = os.environ.get("CODESYS_AGENT_PYTHON_BIN", "python")
|
||||
DEFAULT_TIMEOUT_SEC = int(os.environ.get("CODESYS_AGENT_TIMEOUT_SEC", "180"))
|
||||
ALLOW_DOWNLOAD = os.environ.get("CODESYS_AGENT_ALLOW_DOWNLOAD", "false").lower() == "true"
|
||||
CODESYS_EXE_PATH = os.environ.get("CODESYS_EXE_PATH", "")
|
||||
CODESYS_PROFILE = os.environ.get("CODESYS_PROFILE", "")
|
||||
CODESYS_NO_UI = os.environ.get("CODESYS_NO_UI", "true").lower() == "true"
|
||||
CODESYS_SCRIPT_TIMEOUT_SEC = int(os.environ.get("CODESYS_SCRIPT_TIMEOUT_SEC", "600"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
id: str
|
||||
action: str
|
||||
payload: Dict[str, Any]
|
||||
dry_run: bool = False
|
||||
status: str = "queued"
|
||||
created_at: float = field(default_factory=time.time)
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class SubmitJobRequest(BaseModel):
|
||||
action: str = Field(..., description="Action name")
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class JobStore:
|
||||
def __init__(self) -> None:
|
||||
self._jobs: Dict[str, Job] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def create(self, action: str, payload: Dict[str, Any], dry_run: bool) -> Job:
|
||||
job = Job(
|
||||
id=str(uuid.uuid4()),
|
||||
action=action,
|
||||
payload=payload,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
with self._lock:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> Job:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
raise KeyError(job_id)
|
||||
return job
|
||||
|
||||
def update(self, job: Job) -> None:
|
||||
with self._lock:
|
||||
self._jobs[job.id] = job
|
||||
|
||||
|
||||
app = FastAPI(title="CODESYS VM Agent", version="0.2.0")
|
||||
store = JobStore()
|
||||
job_queue: "queue.Queue[str]" = queue.Queue()
|
||||
|
||||
|
||||
def _check_auth(token: Optional[str]) -> None:
|
||||
if not API_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="CODESYS_AGENT_TOKEN is not configured.",
|
||||
)
|
||||
if token != API_TOKEN:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
def _action_to_script(action: str) -> str:
|
||||
mapping = {
|
||||
"create_project": "create_project.py",
|
||||
"read_project_inventory": "read_project_inventory.py",
|
||||
"read_device_io": "read_device_io.py",
|
||||
"write_pou": "write_pou.py",
|
||||
"write_io_mapping": "write_io_mapping.py",
|
||||
"manage_device": "manage_device.py",
|
||||
"sync_project": "sync_project.py",
|
||||
"stage_existing_project": "stage_existing_project.py",
|
||||
"build_project": "build_project.py",
|
||||
"download_to_device": "download_to_device.py",
|
||||
}
|
||||
script = mapping.get(action)
|
||||
if not script:
|
||||
raise RuntimeError(f"Unsupported action: {action}")
|
||||
if action == "download_to_device" and not ALLOW_DOWNLOAD:
|
||||
raise RuntimeError("download_to_device is disabled by policy.")
|
||||
return script
|
||||
|
||||
|
||||
def _run_script_python(script_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
script_path = (SCRIPTS_DIR / script_name).resolve()
|
||||
if not script_path.is_file():
|
||||
raise RuntimeError(f"Script not found: {script_path}")
|
||||
|
||||
cmd = [PYTHON_BIN, str(script_path), json.dumps(payload)]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=DEFAULT_TIMEOUT_SEC,
|
||||
check=False,
|
||||
)
|
||||
return {
|
||||
"command": cmd,
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": proc.stdout,
|
||||
"stderr": proc.stderr,
|
||||
}
|
||||
|
||||
|
||||
def _run_script_codesys(script_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not CODESYS_EXE_PATH or not CODESYS_PROFILE:
|
||||
raise RuntimeError(
|
||||
"CODESYS_EXE_PATH and CODESYS_PROFILE must be configured for ScriptEngine actions."
|
||||
)
|
||||
script_path = (SCRIPTS_DIR / script_name).resolve()
|
||||
if not script_path.is_file():
|
||||
raise RuntimeError(f"Script not found: {script_path}")
|
||||
|
||||
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload_fd, payload_name = tempfile.mkstemp(
|
||||
prefix="codesys_payload_",
|
||||
suffix=".json",
|
||||
dir=str(RUNTIME_DIR),
|
||||
)
|
||||
result_fd, result_name = tempfile.mkstemp(
|
||||
prefix="codesys_result_",
|
||||
suffix=".json",
|
||||
dir=str(RUNTIME_DIR),
|
||||
)
|
||||
os.close(payload_fd)
|
||||
os.close(result_fd)
|
||||
payload_file = Path(payload_name)
|
||||
result_file = Path(result_name)
|
||||
payload_file.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
script_args = f'--scriptargs:"{payload_file}"'
|
||||
cmd = (
|
||||
f'"{CODESYS_EXE_PATH}" '
|
||||
f'--profile="{CODESYS_PROFILE}" '
|
||||
f'--runscript="{script_path}" '
|
||||
f"{script_args}"
|
||||
)
|
||||
if CODESYS_NO_UI:
|
||||
cmd += " --noUI"
|
||||
|
||||
proc_env = os.environ.copy()
|
||||
proc_env["CODESYS_SCRIPT_RESULT_PATH"] = str(result_file)
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
env=proc_env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=CODESYS_SCRIPT_TIMEOUT_SEC,
|
||||
check=False,
|
||||
)
|
||||
parsed_result: Dict[str, Any] | None = None
|
||||
if result_file.exists():
|
||||
try:
|
||||
parsed_result = json.loads(result_file.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
parsed_result = {"raw_result_text": result_file.read_text(encoding="utf-8", errors="ignore")}
|
||||
|
||||
return {
|
||||
"command": cmd,
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": proc.stdout,
|
||||
"stderr": proc.stderr,
|
||||
"script_result": parsed_result,
|
||||
"payload_file": str(payload_file),
|
||||
"result_file": str(result_file),
|
||||
}
|
||||
|
||||
|
||||
def _execute(job: Job) -> Dict[str, Any]:
|
||||
if job.dry_run:
|
||||
return {
|
||||
"dry_run": True,
|
||||
"action": job.action,
|
||||
"payload": job.payload,
|
||||
"message": "Dry-run accepted. No changes made.",
|
||||
}
|
||||
|
||||
script_name = _action_to_script(job.action)
|
||||
# Project operations use CODESYS ScriptEngine (no manual IDE opening required).
|
||||
codesys_actions = {
|
||||
"create_project",
|
||||
"read_project_inventory",
|
||||
"read_device_io",
|
||||
"write_pou",
|
||||
"write_io_mapping",
|
||||
"manage_device",
|
||||
"sync_project",
|
||||
"build_project",
|
||||
"download_to_device",
|
||||
}
|
||||
if job.action in codesys_actions:
|
||||
result = _run_script_codesys(script_name, job.payload)
|
||||
else:
|
||||
result = _run_script_python(script_name, job.payload)
|
||||
if result["exit_code"] != 0:
|
||||
raise RuntimeError(
|
||||
f"{job.action} failed with exit_code={result['exit_code']} stderr={result['stderr']}"
|
||||
)
|
||||
if result.get("script_result"):
|
||||
return result["script_result"]
|
||||
return result
|
||||
|
||||
|
||||
def _worker() -> None:
|
||||
while True:
|
||||
job_id = job_queue.get()
|
||||
try:
|
||||
job = store.get(job_id)
|
||||
job.status = "running"
|
||||
job.started_at = time.time()
|
||||
store.update(job)
|
||||
|
||||
result = _execute(job)
|
||||
job.status = "succeeded"
|
||||
job.result = result
|
||||
job.finished_at = time.time()
|
||||
store.update(job)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
job = store.get(job_id)
|
||||
job.status = "failed"
|
||||
job.error = str(exc)
|
||||
job.finished_at = time.time()
|
||||
store.update(job)
|
||||
finally:
|
||||
job_queue.task_done()
|
||||
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"service": "codesys-vm-agent",
|
||||
"version": "0.2.0",
|
||||
"scripts_dir": str(SCRIPTS_DIR),
|
||||
"runtime_dir": str(RUNTIME_DIR),
|
||||
"download_enabled": ALLOW_DOWNLOAD,
|
||||
"codesys_exe_configured": bool(CODESYS_EXE_PATH),
|
||||
"codesys_profile_configured": bool(CODESYS_PROFILE),
|
||||
"codesys_no_ui": CODESYS_NO_UI,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/jobs")
|
||||
def submit_job(req: SubmitJobRequest, x_api_token: Optional[str] = Header(None)) -> Dict[str, Any]:
|
||||
_check_auth(x_api_token)
|
||||
job = store.create(req.action, req.payload, req.dry_run)
|
||||
job_queue.put(job.id)
|
||||
return {"job_id": job.id, "status": job.status}
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}")
|
||||
def get_job(job_id: str, x_api_token: Optional[str] = Header(None)) -> Dict[str, Any]:
|
||||
_check_auth(x_api_token)
|
||||
try:
|
||||
job = store.get(job_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="Job not found") from exc
|
||||
return asdict(job)
|
||||
2
python_scripting/windows_codesys_agent/requirements.txt
Normal file
2
python_scripting/windows_codesys_agent/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn>=0.30.0
|
||||
78
python_scripting/windows_codesys_agent/scripts/_common.py
Normal file
78
python_scripting/windows_codesys_agent/scripts/_common.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python
|
||||
"""Shared helpers for Windows CODESYS action scripts."""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
text_type = unicode # type: ignore[name-defined]
|
||||
binary_type = str
|
||||
PY2 = True
|
||||
except NameError:
|
||||
text_type = str
|
||||
binary_type = bytes
|
||||
PY2 = False
|
||||
|
||||
|
||||
def _argv(index):
|
||||
if len(sys.argv) > index:
|
||||
return sys.argv[index]
|
||||
return None
|
||||
|
||||
|
||||
def load_payload():
|
||||
"""Load payload from argv[1] (json string or json file path)."""
|
||||
raw = _argv(1)
|
||||
if not raw:
|
||||
raise ValueError("Missing payload argument.")
|
||||
if os.path.exists(raw):
|
||||
with open(raw, "r") as f:
|
||||
return json.loads(f.read())
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid JSON payload: {0}".format(exc))
|
||||
|
||||
|
||||
def _normalize_text(value):
|
||||
if isinstance(value, dict):
|
||||
normalized = {}
|
||||
for key, item in value.items():
|
||||
normalized[_normalize_text(key)] = _normalize_text(item)
|
||||
return normalized
|
||||
if isinstance(value, list):
|
||||
return [_normalize_text(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_normalize_text(item) for item in value]
|
||||
if isinstance(value, binary_type):
|
||||
if PY2:
|
||||
for encoding in ("utf-8", "cp1252", "latin-1"):
|
||||
try:
|
||||
return value.decode(encoding).encode("ascii", "replace")
|
||||
except Exception:
|
||||
pass
|
||||
return value.decode("latin-1", "replace").encode("ascii", "replace")
|
||||
for encoding in ("utf-8", "cp1252", "latin-1"):
|
||||
try:
|
||||
return value.decode(encoding).encode("ascii", "replace").decode("ascii")
|
||||
except Exception:
|
||||
pass
|
||||
return value.decode("latin-1", "replace").encode("ascii", "replace").decode("ascii")
|
||||
if isinstance(value, text_type):
|
||||
if PY2:
|
||||
return value.encode("ascii", "replace")
|
||||
return value.encode("ascii", "replace").decode("ascii")
|
||||
return value
|
||||
|
||||
|
||||
def emit(data):
|
||||
"""Print JSON to stdout and optionally write to argv[2] path."""
|
||||
text = json.dumps(_normalize_text(data), indent=2, ensure_ascii=True)
|
||||
print(text)
|
||||
result_path = _argv(2) or os.environ.get("CODESYS_SCRIPT_RESULT_PATH")
|
||||
if result_path:
|
||||
with open(result_path, "w") as f:
|
||||
f.write(text)
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python
|
||||
"""Build/compile a CODESYS project via ScriptEngine."""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
|
||||
from _common import emit, load_payload
|
||||
|
||||
|
||||
def main():
|
||||
payload = load_payload()
|
||||
project_path = payload.get("project_path")
|
||||
save_after_build = bool(payload.get("save_after_build", True))
|
||||
if not project_path:
|
||||
raise ValueError("project_path is required.")
|
||||
pp = os.path.abspath(os.path.expanduser(project_path))
|
||||
if not os.path.exists(pp):
|
||||
raise RuntimeError("Project path does not exist: {0}".format(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, allow_readonly=False)
|
||||
app = proj.active_application
|
||||
if app is None:
|
||||
raise RuntimeError("No active application found in project.")
|
||||
|
||||
app.build()
|
||||
if save_after_build:
|
||||
proj.save()
|
||||
proj.close()
|
||||
|
||||
emit(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "build_project",
|
||||
"project_path": pp,
|
||||
"save_after_build": save_after_build,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python
|
||||
"""Create a new CODESYS project via ScriptEngine."""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
|
||||
from _common import emit, load_payload
|
||||
|
||||
|
||||
def main():
|
||||
payload = load_payload()
|
||||
project_name = payload.get("project_name")
|
||||
output_dir = payload.get("output_dir")
|
||||
template = payload.get("template", "standard")
|
||||
overwrite = bool(payload.get("overwrite", False))
|
||||
|
||||
if not project_name or not output_dir:
|
||||
raise ValueError("project_name and output_dir are required.")
|
||||
|
||||
project_path = os.path.join(output_dir, "{0}.project".format(project_name))
|
||||
if os.path.exists(project_path) and not overwrite:
|
||||
raise RuntimeError("Project already exists: {0}".format(project_path))
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
if "projects" not in globals():
|
||||
raise RuntimeError(
|
||||
"CODESYS ScriptEngine globals not found. "
|
||||
"Run this script through CODESYS.exe --runscript."
|
||||
)
|
||||
|
||||
if projects.primary:
|
||||
projects.primary.close()
|
||||
|
||||
if os.path.exists(project_path) and overwrite:
|
||||
os.remove(project_path)
|
||||
|
||||
proj = projects.create(project_path, primary=True)
|
||||
proj.save()
|
||||
proj.close()
|
||||
|
||||
emit(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "create_project",
|
||||
"project_name": project_name,
|
||||
"template": template,
|
||||
"project_path": project_path,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python
|
||||
"""Login/download/start the active application using ScriptEngine online API."""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
|
||||
from _common import emit, load_payload
|
||||
|
||||
|
||||
def main():
|
||||
payload = load_payload()
|
||||
project_path = payload.get("project_path")
|
||||
target_name = payload.get("target_name", "")
|
||||
start_application = bool(payload.get("start_application", False))
|
||||
force_download = bool(payload.get("force_download", True))
|
||||
|
||||
if not project_path:
|
||||
raise ValueError("project_path is required.")
|
||||
pp = os.path.abspath(os.path.expanduser(project_path))
|
||||
if not os.path.exists(pp):
|
||||
raise RuntimeError("Project path does not exist: {0}".format(pp))
|
||||
if "projects" not in globals() or "online" not in globals():
|
||||
raise RuntimeError("Run through CODESYS ScriptEngine (--runscript).")
|
||||
|
||||
if projects.primary:
|
||||
projects.primary.close()
|
||||
proj = projects.open(pp, primary=True, allow_readonly=False)
|
||||
app = proj.active_application
|
||||
if app is None:
|
||||
raise RuntimeError("No active application found in project.")
|
||||
|
||||
online_app = online.create_online_application(app)
|
||||
# Try online change first; CODESYS handles download when needed.
|
||||
login_mode = OnlineChangeOption.Try if force_download else OnlineChangeOption.Never
|
||||
online_app.login(login_mode, True)
|
||||
|
||||
started = False
|
||||
if start_application and online_app.application_state != ApplicationState.run:
|
||||
online_app.start()
|
||||
started = True
|
||||
|
||||
online_app.logout()
|
||||
proj.close()
|
||||
|
||||
emit(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "download_to_device",
|
||||
"project_path": pp,
|
||||
"target_name": target_name,
|
||||
"start_application": start_application,
|
||||
"started": started,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
318
python_scripting/windows_codesys_agent/scripts/manage_device.py
Normal file
318
python_scripting/windows_codesys_agent/scripts/manage_device.py
Normal file
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python
|
||||
"""Add, remove, or update devices in a CODESYS project via ScriptEngine."""
|
||||
|
||||
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:
|
||||
raise RuntimeError("Not found: '{0}' at segment {1} of '{2}'".format(part, i, path_str))
|
||||
current = found
|
||||
return current
|
||||
|
||||
|
||||
def _get_device_id(obj):
|
||||
try:
|
||||
did = obj.get_device_identification()
|
||||
return {"type": int(did.type), "id": _safe_str(did.id), "version": _safe_str(did.version)}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _action_add(proj, op, warnings):
|
||||
parent_path = op.get("parent_path", "")
|
||||
name = op.get("name", "")
|
||||
dev_type = op.get("device_type")
|
||||
dev_id = op.get("device_id", "")
|
||||
dev_version = op.get("device_version", "")
|
||||
module_id = op.get("module_id")
|
||||
|
||||
if not parent_path or not name or dev_type is None or not dev_id or not dev_version:
|
||||
raise ValueError("add requires: parent_path, name, device_type, device_id, device_version")
|
||||
|
||||
parent = _find_by_path(proj, parent_path)
|
||||
args = [name, int(dev_type), dev_id, dev_version]
|
||||
if module_id is not None:
|
||||
args.append(module_id)
|
||||
|
||||
method = op.get("method", "add")
|
||||
if method == "plug":
|
||||
parent.plug(*args)
|
||||
elif method == "insert":
|
||||
index = int(op.get("index", -1))
|
||||
parent.insert(name, index, int(dev_type), dev_id, dev_version)
|
||||
else:
|
||||
parent.add(*args)
|
||||
|
||||
return {
|
||||
"action": "add",
|
||||
"parent_path": parent_path,
|
||||
"name": name,
|
||||
"device_type": dev_type,
|
||||
"device_id": dev_id,
|
||||
"device_version": dev_version,
|
||||
"status": "added",
|
||||
}
|
||||
|
||||
|
||||
def _action_remove(proj, op, warnings):
|
||||
device_path = op.get("device_path", "")
|
||||
if not device_path:
|
||||
raise ValueError("remove requires: device_path")
|
||||
obj = _find_by_path(proj, device_path)
|
||||
name = _name_of(obj)
|
||||
dev_id = _get_device_id(obj)
|
||||
obj.remove()
|
||||
return {
|
||||
"action": "remove",
|
||||
"device_path": device_path,
|
||||
"name": name,
|
||||
"device_id": dev_id,
|
||||
"status": "removed",
|
||||
}
|
||||
|
||||
|
||||
def _action_update(proj, op, warnings):
|
||||
device_path = op.get("device_path", "")
|
||||
dev_type = op.get("device_type")
|
||||
dev_id = op.get("device_id", "")
|
||||
dev_version = op.get("device_version", "")
|
||||
module_id = op.get("module_id")
|
||||
|
||||
if not device_path or dev_type is None or not dev_id or not dev_version:
|
||||
raise ValueError("update requires: device_path, device_type, device_id, device_version")
|
||||
|
||||
obj = _find_by_path(proj, device_path)
|
||||
old_id = _get_device_id(obj)
|
||||
|
||||
args = [int(dev_type), dev_id, dev_version]
|
||||
if module_id is not None:
|
||||
args.append(module_id)
|
||||
obj.update(*args)
|
||||
|
||||
return {
|
||||
"action": "update",
|
||||
"device_path": device_path,
|
||||
"old_device_id": old_id,
|
||||
"new_device_id": {"type": dev_type, "id": dev_id, "version": dev_version},
|
||||
"status": "updated",
|
||||
}
|
||||
|
||||
|
||||
def _action_rename(proj, op, warnings):
|
||||
device_path = op.get("device_path", "")
|
||||
new_name = op.get("new_name", "")
|
||||
if not device_path or not new_name:
|
||||
raise ValueError("rename requires: device_path, new_name")
|
||||
obj = _find_by_path(proj, device_path)
|
||||
old_name = _name_of(obj)
|
||||
obj.rename(new_name)
|
||||
return {
|
||||
"action": "rename",
|
||||
"device_path": device_path,
|
||||
"old_name": old_name,
|
||||
"new_name": new_name,
|
||||
"status": "renamed",
|
||||
}
|
||||
|
||||
|
||||
def _action_enable(proj, op, warnings):
|
||||
device_path = op.get("device_path", "")
|
||||
enabled = op.get("enabled", True)
|
||||
if not device_path:
|
||||
raise ValueError("enable/disable requires: device_path")
|
||||
obj = _find_by_path(proj, device_path)
|
||||
if enabled:
|
||||
obj.enable()
|
||||
else:
|
||||
obj.disable()
|
||||
return {
|
||||
"action": "enable" if enabled else "disable",
|
||||
"device_path": device_path,
|
||||
"status": "enabled" if enabled else "disabled",
|
||||
}
|
||||
|
||||
|
||||
def _action_set_param(proj, op, warnings):
|
||||
"""Set a device parameter value by id or name."""
|
||||
device_path = op.get("device_path", "")
|
||||
param_id = op.get("param_id")
|
||||
param_name = op.get("param_name", "")
|
||||
value = op.get("value")
|
||||
|
||||
if not device_path:
|
||||
raise ValueError("set_param requires: device_path")
|
||||
if value is None:
|
||||
raise ValueError("set_param requires: value")
|
||||
|
||||
obj = _find_by_path(proj, device_path)
|
||||
params = obj.device_parameters
|
||||
target = None
|
||||
|
||||
for p in list(params):
|
||||
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 or _safe_str(getattr(p, "name", "")) == param_name):
|
||||
target = p
|
||||
break
|
||||
|
||||
if target is None:
|
||||
raise RuntimeError("Parameter not found: id={0} name={1} on {2}".format(param_id, param_name, device_path))
|
||||
|
||||
old_value = _safe_str(target.value) if hasattr(target, "value") else "?"
|
||||
target.value = _safe_str(value)
|
||||
|
||||
return {
|
||||
"action": "set_param",
|
||||
"device_path": device_path,
|
||||
"param_id": int(target.id),
|
||||
"param_name": _safe_str(getattr(target, "visible_name", "")),
|
||||
"old_value": old_value,
|
||||
"new_value": _safe_str(value),
|
||||
"status": "set",
|
||||
}
|
||||
|
||||
|
||||
ACTIONS = {
|
||||
"add": _action_add,
|
||||
"remove": _action_remove,
|
||||
"update": _action_update,
|
||||
"rename": _action_rename,
|
||||
"enable": _action_enable,
|
||||
"disable": _action_enable,
|
||||
"set_param": _action_set_param,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
payload = load_payload()
|
||||
project_path = payload.get("project_path")
|
||||
if not project_path:
|
||||
raise ValueError("project_path is required.")
|
||||
|
||||
operations = payload.get("operations", [])
|
||||
if not operations:
|
||||
single_action = payload.get("action_type", "")
|
||||
if single_action:
|
||||
operations = [payload]
|
||||
else:
|
||||
raise ValueError("Provide 'operations' list or a single operation with 'action_type'.")
|
||||
|
||||
dry_run = bool(payload.get("dry_run", False))
|
||||
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 = []
|
||||
|
||||
for op in operations:
|
||||
action_type = op.get("action_type", "")
|
||||
handler = ACTIONS.get(action_type)
|
||||
if not handler:
|
||||
warnings.append("unknown action_type: {0}".format(action_type))
|
||||
continue
|
||||
|
||||
try:
|
||||
if dry_run:
|
||||
results.append({
|
||||
"action": action_type,
|
||||
"dry_run": True,
|
||||
"status": "would_execute",
|
||||
"details": op,
|
||||
})
|
||||
else:
|
||||
result = handler(proj, op, warnings)
|
||||
results.append(result)
|
||||
except Exception as exc:
|
||||
warnings.append("{0}_failed: {1}".format(action_type, exc))
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
proj.save()
|
||||
except Exception as exc:
|
||||
warnings.append("project_save_failed: {0}".format(exc))
|
||||
|
||||
proj.close()
|
||||
|
||||
emit({
|
||||
"ok": True,
|
||||
"action": "manage_device",
|
||||
"project_path": pp,
|
||||
"dry_run": dry_run,
|
||||
"results": results,
|
||||
"result_count": len(results),
|
||||
"warnings": warnings,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
303
python_scripting/windows_codesys_agent/scripts/read_device_io.py
Normal file
303
python_scripting/windows_codesys_agent/scripts/read_device_io.py
Normal file
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python
|
||||
"""Read device I/O channels, parameters, and variable mappings from a CODESYS project."""
|
||||
|
||||
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 point to a .project or .projectarchive file. Got: {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 directory: {0}".format(path_value))
|
||||
raise RuntimeError("Multiple project files found: {0}".format(", ".join(candidates)))
|
||||
raise RuntimeError("Project 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 _get_device_id(obj):
|
||||
try:
|
||||
did = obj.get_device_identification()
|
||||
return {
|
||||
"type": int(did.type),
|
||||
"id": _safe_str(did.id),
|
||||
"version": _safe_str(did.version),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _channel_type_str(ct):
|
||||
mapping = {0: "none", 1: "input", 2: "output", 3: "output_readonly"}
|
||||
try:
|
||||
return mapping.get(int(ct), _safe_str(ct))
|
||||
except Exception:
|
||||
return _safe_str(ct)
|
||||
|
||||
|
||||
def _read_io_mapping(param):
|
||||
"""Extract IO mapping info from a device parameter."""
|
||||
try:
|
||||
iom = param.io_mapping
|
||||
if iom is None:
|
||||
return None
|
||||
result = {}
|
||||
try:
|
||||
result["variable"] = _safe_str(iom.variable) if iom.variable else ""
|
||||
except Exception:
|
||||
result["variable"] = ""
|
||||
try:
|
||||
result["default_variable"] = _safe_str(iom.default_variable) if iom.default_variable else ""
|
||||
except Exception:
|
||||
result["default_variable"] = ""
|
||||
try:
|
||||
result["creates_variable"] = bool(iom.mapping_creates_variable)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result["maps_to_existing"] = bool(iom.maps_to_existing_variable)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result["auto_iec_address"] = bool(iom.automatic_iec_address)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
addr = iom.manual_iec_address
|
||||
result["iec_address"] = _safe_str(addr) if addr else ""
|
||||
except Exception:
|
||||
result["iec_address"] = ""
|
||||
return result
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_params(param_set, channels_only):
|
||||
"""Read device parameters, optionally filtering to I/O channels only."""
|
||||
params = []
|
||||
try:
|
||||
param_list = list(param_set)
|
||||
except Exception:
|
||||
return params
|
||||
|
||||
for param in param_list:
|
||||
try:
|
||||
ct = param.channel_type
|
||||
ct_str = _channel_type_str(ct)
|
||||
if channels_only and ct_str == "none":
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"name": _safe_str(getattr(param, "visible_name", "") or getattr(param, "name", "")),
|
||||
"id": int(param.id) if hasattr(param, "id") else -1,
|
||||
"channel_type": ct_str,
|
||||
"bit_size": int(param.bit_size) if hasattr(param, "bit_size") else 0,
|
||||
}
|
||||
try:
|
||||
entry["iec_type"] = _safe_str(param.iec_type) if param.iec_type else ""
|
||||
except Exception:
|
||||
entry["iec_type"] = ""
|
||||
try:
|
||||
entry["type_string"] = _safe_str(param.type_string)
|
||||
except Exception:
|
||||
entry["type_string"] = ""
|
||||
try:
|
||||
entry["section"] = _safe_str(param.section) if param.section else ""
|
||||
except Exception:
|
||||
entry["section"] = ""
|
||||
|
||||
mapping = _read_io_mapping(param)
|
||||
if mapping is not None:
|
||||
entry["io_mapping"] = mapping
|
||||
|
||||
if getattr(param, "has_sub_elements", False):
|
||||
sub_entries = []
|
||||
try:
|
||||
for sub in list(param):
|
||||
sub_entry = {
|
||||
"name": _safe_str(getattr(sub, "visible_name", "") or getattr(sub, "identifier", "")),
|
||||
"bit_size": int(sub.bit_size) if hasattr(sub, "bit_size") else 0,
|
||||
}
|
||||
try:
|
||||
sub_entry["iec_type"] = _safe_str(sub.iec_type) if hasattr(sub, "iec_type") and sub.iec_type else ""
|
||||
except Exception:
|
||||
pass
|
||||
sub_mapping = _read_io_mapping(sub)
|
||||
if sub_mapping is not None:
|
||||
sub_entry["io_mapping"] = sub_mapping
|
||||
sub_entries.append(sub_entry)
|
||||
except Exception:
|
||||
pass
|
||||
if sub_entries:
|
||||
entry["sub_elements"] = sub_entries
|
||||
|
||||
params.append(entry)
|
||||
except Exception:
|
||||
continue
|
||||
return params
|
||||
|
||||
|
||||
def _walk_devices(node, path_parts, results, warnings, channels_only, max_depth, depth):
|
||||
if depth > max_depth:
|
||||
return
|
||||
|
||||
name = _name_of(node)
|
||||
is_dev = getattr(node, "is_device", False)
|
||||
|
||||
if is_dev:
|
||||
device_info = {
|
||||
"name": name,
|
||||
"path": "/".join(path_parts + [name]),
|
||||
}
|
||||
|
||||
dev_id = _get_device_id(node)
|
||||
if dev_id:
|
||||
device_info["device_id"] = dev_id
|
||||
|
||||
try:
|
||||
params = node.device_parameters
|
||||
device_info["parameters"] = _read_params(params, channels_only)
|
||||
device_info["parameter_count"] = len(device_info["parameters"])
|
||||
except Exception as exc:
|
||||
device_info["parameters"] = []
|
||||
device_info["parameter_count"] = 0
|
||||
warnings.append("params_failed({0}): {1}".format(name, exc))
|
||||
|
||||
connectors_info = []
|
||||
try:
|
||||
conn_set = node.connectors
|
||||
for conn in list(conn_set):
|
||||
ci = {
|
||||
"connector_id": int(conn.connector_id),
|
||||
"interface": _safe_str(conn.interface),
|
||||
"role": "parent" if int(conn.connector_role) == 0 else "child",
|
||||
}
|
||||
try:
|
||||
hp = conn.host_parameters
|
||||
ci["host_parameters"] = _read_params(hp, channels_only)
|
||||
except Exception:
|
||||
pass
|
||||
connectors_info.append(ci)
|
||||
except Exception as exc:
|
||||
warnings.append("connectors_failed({0}): {1}".format(name, exc))
|
||||
|
||||
if connectors_info:
|
||||
device_info["connectors"] = connectors_info
|
||||
|
||||
results.append(device_info)
|
||||
|
||||
for child in _children_of(node):
|
||||
try:
|
||||
_walk_devices(child, path_parts + [name], results, warnings, channels_only, max_depth, depth + 1)
|
||||
except Exception as exc:
|
||||
warnings.append("walk_child_failed({0}): {1}".format(name, exc))
|
||||
|
||||
|
||||
def main():
|
||||
payload = load_payload()
|
||||
project_path = payload.get("project_path")
|
||||
if not project_path:
|
||||
raise ValueError("project_path is required.")
|
||||
|
||||
channels_only = bool(payload.get("channels_only", True))
|
||||
max_depth = int(payload.get("max_depth", 10))
|
||||
export_csv_path = payload.get("export_csv_path", "")
|
||||
|
||||
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()
|
||||
|
||||
if pp.lower().endswith(".projectarchive"):
|
||||
project_dir = os.path.dirname(pp)
|
||||
proj = projects.open_archive(pp, project_dir, overwrite=False)
|
||||
else:
|
||||
proj = projects.open(pp, primary=True, allow_readonly=True)
|
||||
|
||||
devices = []
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
top_nodes = _children_of(proj)
|
||||
except Exception as exc:
|
||||
top_nodes = []
|
||||
warnings.append("top_level_enumeration_failed: {0}".format(exc))
|
||||
|
||||
for top in top_nodes:
|
||||
try:
|
||||
_walk_devices(top, [], devices, warnings, channels_only, max_depth, 0)
|
||||
except Exception as exc:
|
||||
warnings.append("walk_failed: {0}".format(exc))
|
||||
|
||||
if export_csv_path:
|
||||
try:
|
||||
for top in top_nodes:
|
||||
if getattr(top, "is_device", False):
|
||||
top.export_io_mappings_as_csv(export_csv_path)
|
||||
break
|
||||
except Exception as exc:
|
||||
warnings.append("csv_export_failed: {0}".format(exc))
|
||||
|
||||
proj.close()
|
||||
|
||||
emit({
|
||||
"ok": True,
|
||||
"action": "read_device_io",
|
||||
"project_path": pp,
|
||||
"devices": devices,
|
||||
"device_count": len(devices),
|
||||
"channels_only": channels_only,
|
||||
"warnings": warnings,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python
|
||||
"""Read existing CODESYS project inventory via ScriptEngine."""
|
||||
|
||||
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 point to a .project or .projectarchive file. Got: {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 or .projectarchive file found in directory: {0}".format(path_value)
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Multiple project files found in directory. Specify project_path explicitly: {0}".format(
|
||||
", ".join(candidates)
|
||||
)
|
||||
)
|
||||
|
||||
raise RuntimeError("Project path does not exist: {0}".format(path_value))
|
||||
|
||||
|
||||
def _name_of(obj):
|
||||
try:
|
||||
return obj.get_name(False)
|
||||
except Exception:
|
||||
try:
|
||||
return obj.get_name()
|
||||
except Exception:
|
||||
return "{0}".format(obj)
|
||||
|
||||
|
||||
def _children_of(obj):
|
||||
try:
|
||||
return list(obj.get_children(False))
|
||||
except Exception:
|
||||
try:
|
||||
return list(obj.get_children())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _walk(node, depth, max_depth, rows):
|
||||
if depth > max_depth:
|
||||
return
|
||||
kind = "node"
|
||||
for attr in ("is_device", "is_application", "is_folder"):
|
||||
if getattr(node, attr, False):
|
||||
kind = attr.replace("is_", "")
|
||||
break
|
||||
rows.append({"name": _name_of(node), "kind": kind, "depth": depth})
|
||||
for child in _children_of(node):
|
||||
_walk(child, depth + 1, max_depth, rows)
|
||||
|
||||
|
||||
def _text_from_doc(doc):
|
||||
try:
|
||||
return doc.text
|
||||
except Exception:
|
||||
try:
|
||||
return doc.get_text(0, doc.length)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _collect_textual(node, path_names, collected):
|
||||
name = _name_of(node)
|
||||
decl = None
|
||||
impl = None
|
||||
try:
|
||||
if getattr(node, "has_textual_declaration", False):
|
||||
decl = _text_from_doc(node.textual_declaration)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if getattr(node, "has_textual_implementation", False):
|
||||
impl = _text_from_doc(node.textual_implementation)
|
||||
except Exception:
|
||||
pass
|
||||
if decl is not None or impl is not None:
|
||||
collected.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": " / ".join(path_names + [name]),
|
||||
"has_declaration": decl is not None,
|
||||
"has_implementation": impl is not None,
|
||||
"declaration": decl or "",
|
||||
"implementation": impl or "",
|
||||
}
|
||||
)
|
||||
for child in _children_of(node):
|
||||
_collect_textual(child, path_names + [name], collected)
|
||||
|
||||
|
||||
def main():
|
||||
payload = load_payload()
|
||||
project_path = payload.get("project_path")
|
||||
max_depth = int(payload.get("max_depth", 3))
|
||||
include_textual_sources = bool(payload.get("include_textual_sources", True))
|
||||
if not project_path:
|
||||
raise ValueError("project_path is required.")
|
||||
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()
|
||||
|
||||
if pp.lower().endswith(".projectarchive"):
|
||||
project_dir = os.path.dirname(pp)
|
||||
proj = projects.open_archive(pp, project_dir, overwrite=False)
|
||||
else:
|
||||
proj = projects.open(pp, primary=True, allow_readonly=True)
|
||||
|
||||
rows = []
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
top_nodes = _children_of(proj)
|
||||
except Exception as exc:
|
||||
top_nodes = []
|
||||
warnings.append("top_level_enumeration_failed: {0}".format(exc))
|
||||
|
||||
for top in top_nodes:
|
||||
try:
|
||||
_walk(top, depth=0, max_depth=max_depth, rows=rows)
|
||||
except Exception as exc:
|
||||
warnings.append("walk_failed_for_node: {0}".format(exc))
|
||||
|
||||
textual_sources = []
|
||||
if include_textual_sources:
|
||||
for top in top_nodes:
|
||||
try:
|
||||
_collect_textual(top, [], textual_sources)
|
||||
except Exception as exc:
|
||||
warnings.append("textual_collect_failed: {0}".format(exc))
|
||||
|
||||
active_app_name = None
|
||||
try:
|
||||
active_app = getattr(proj, "active_application", None)
|
||||
except Exception as exc:
|
||||
active_app = None
|
||||
warnings.append("active_application_unavailable: {0}".format(exc))
|
||||
if active_app is not None:
|
||||
try:
|
||||
active_app_name = _name_of(active_app)
|
||||
except Exception as exc:
|
||||
warnings.append("active_application_name_failed: {0}".format(exc))
|
||||
|
||||
proj.close()
|
||||
emit(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "read_project_inventory",
|
||||
"project_path": pp,
|
||||
"active_application": active_app_name,
|
||||
"nodes": rows,
|
||||
"node_count": len(rows),
|
||||
"textual_sources": textual_sources,
|
||||
"textual_source_count": len(textual_sources),
|
||||
"warnings": warnings,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python
|
||||
"""Stage an existing CODESYS project into a standardized projects root folder."""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from _common import emit, load_payload
|
||||
|
||||
|
||||
def _is_project_file(name):
|
||||
lower_name = name.lower()
|
||||
return lower_name.endswith(".project") or lower_name.endswith(".projectarchive")
|
||||
|
||||
|
||||
def _sanitize_folder_name(name):
|
||||
cleaned = re.sub(r'[<>:"/\\|?*]+', "_", name or "")
|
||||
cleaned = cleaned.strip().strip(".")
|
||||
return cleaned or "codesys_project"
|
||||
|
||||
|
||||
def _discover_project_file(source_path):
|
||||
if os.path.isfile(source_path):
|
||||
if _is_project_file(source_path):
|
||||
return source_path
|
||||
raise RuntimeError(
|
||||
"source_path file must be .project or .projectarchive. Got: {0}".format(source_path)
|
||||
)
|
||||
|
||||
if not os.path.isdir(source_path):
|
||||
raise RuntimeError("source_path does not exist: {0}".format(source_path))
|
||||
|
||||
candidates = []
|
||||
for root, _, files in os.walk(source_path):
|
||||
for file_name in files:
|
||||
if _is_project_file(file_name):
|
||||
candidates.append(os.path.join(root, file_name))
|
||||
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if not candidates:
|
||||
raise RuntimeError("No .project or .projectarchive found under: {0}".format(source_path))
|
||||
raise RuntimeError(
|
||||
"Multiple project files found under source_path. Use source_path as a specific file: {0}".format(
|
||||
", ".join(candidates)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _copy_or_move(source_dir, target_dir, move_mode):
|
||||
if move_mode:
|
||||
shutil.move(source_dir, target_dir)
|
||||
else:
|
||||
shutil.copytree(source_dir, target_dir)
|
||||
|
||||
|
||||
def main():
|
||||
payload = load_payload()
|
||||
source_path = payload.get("source_path")
|
||||
projects_root_dir = payload.get("projects_root_dir")
|
||||
project_folder_name = payload.get("project_folder_name")
|
||||
move_mode = bool(payload.get("move", False))
|
||||
overwrite = bool(payload.get("overwrite", False))
|
||||
|
||||
if not source_path or not projects_root_dir:
|
||||
raise ValueError("source_path and projects_root_dir are required.")
|
||||
|
||||
source_abs = os.path.abspath(os.path.expanduser(source_path))
|
||||
root_abs = os.path.abspath(os.path.expanduser(projects_root_dir))
|
||||
if not os.path.exists(source_abs):
|
||||
raise RuntimeError("source_path does not exist: {0}".format(source_abs))
|
||||
|
||||
project_file_abs = _discover_project_file(source_abs)
|
||||
source_dir_abs = os.path.dirname(project_file_abs)
|
||||
inferred_folder_name = os.path.basename(source_dir_abs)
|
||||
folder_name = _sanitize_folder_name(project_folder_name or inferred_folder_name)
|
||||
|
||||
if not os.path.exists(root_abs):
|
||||
os.makedirs(root_abs)
|
||||
|
||||
target_dir_abs = os.path.join(root_abs, folder_name)
|
||||
if os.path.exists(target_dir_abs):
|
||||
if not overwrite:
|
||||
raise RuntimeError("Target project folder already exists: {0}".format(target_dir_abs))
|
||||
if os.path.isdir(target_dir_abs):
|
||||
shutil.rmtree(target_dir_abs)
|
||||
else:
|
||||
os.remove(target_dir_abs)
|
||||
|
||||
_copy_or_move(source_dir_abs, target_dir_abs, move_mode)
|
||||
|
||||
relative_project_path = os.path.relpath(project_file_abs, source_dir_abs)
|
||||
staged_project_path = os.path.join(target_dir_abs, relative_project_path)
|
||||
if not os.path.exists(staged_project_path):
|
||||
raise RuntimeError(
|
||||
"Staged project file not found after transfer: {0}".format(staged_project_path)
|
||||
)
|
||||
|
||||
emit(
|
||||
{
|
||||
"ok": True,
|
||||
"action": "stage_existing_project",
|
||||
"source_path": source_abs,
|
||||
"projects_root_dir": root_abs,
|
||||
"project_folder": target_dir_abs,
|
||||
"project_path": staged_project_path,
|
||||
"moved": move_mode,
|
||||
"overwritten": overwrite,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
314
python_scripting/windows_codesys_agent/scripts/sync_project.py
Normal file
314
python_scripting/windows_codesys_agent/scripts/sync_project.py
Normal file
@@ -0,0 +1,314 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python
|
||||
"""Write I/O variable mappings in a CODESYS project via ScriptEngine."""
|
||||
|
||||
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 point to a .project or .projectarchive file. Got: {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 directory: {0}".format(path_value))
|
||||
raise RuntimeError("Multiple project files found: {0}".format(", ".join(candidates)))
|
||||
raise RuntimeError("Project 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_device_by_path(proj, device_path):
|
||||
"""Walk the tree to find a device node matching the given slash-separated path."""
|
||||
parts = [p.strip() for p in device_path.split("/") if p.strip()]
|
||||
current_nodes = _children_of(proj)
|
||||
target = None
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
found = None
|
||||
for node in current_nodes:
|
||||
if _name_of(node) == part:
|
||||
found = node
|
||||
break
|
||||
if found is None:
|
||||
raise RuntimeError(
|
||||
"Device path segment '{0}' not found at depth {1}. Path: {2}".format(part, i, device_path)
|
||||
)
|
||||
if i == len(parts) - 1:
|
||||
target = found
|
||||
else:
|
||||
current_nodes = _children_of(found)
|
||||
|
||||
return target
|
||||
|
||||
|
||||
def _find_param(param_set, param_id=None, param_name=None):
|
||||
"""Find a parameter by numeric id or name."""
|
||||
for param in list(param_set):
|
||||
if param_id is not None:
|
||||
try:
|
||||
if int(param.id) == int(param_id):
|
||||
return param
|
||||
except Exception:
|
||||
pass
|
||||
if param_name is not None:
|
||||
try:
|
||||
pname = _safe_str(getattr(param, "visible_name", "") or getattr(param, "name", ""))
|
||||
if pname == param_name:
|
||||
return param
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
payload = load_payload()
|
||||
project_path = payload.get("project_path")
|
||||
if not project_path:
|
||||
raise ValueError("project_path is required.")
|
||||
|
||||
mappings = payload.get("mappings", [])
|
||||
import_csv_path = payload.get("import_csv_path", "")
|
||||
dry_run = bool(payload.get("dry_run", False))
|
||||
|
||||
if not mappings and not import_csv_path:
|
||||
raise ValueError("Provide 'mappings' list or 'import_csv_path'.")
|
||||
|
||||
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 import_csv_path:
|
||||
try:
|
||||
for top in _children_of(proj):
|
||||
if getattr(top, "is_device", False):
|
||||
if dry_run:
|
||||
results.append({
|
||||
"action": "import_csv",
|
||||
"csv_path": import_csv_path,
|
||||
"dry_run": True,
|
||||
"status": "would_import",
|
||||
})
|
||||
else:
|
||||
top.import_io_mappings_from_csv(import_csv_path)
|
||||
results.append({
|
||||
"action": "import_csv",
|
||||
"csv_path": import_csv_path,
|
||||
"status": "imported",
|
||||
})
|
||||
break
|
||||
except Exception as exc:
|
||||
warnings.append("csv_import_failed: {0}".format(exc))
|
||||
|
||||
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:
|
||||
warnings.append("mapping skipped: missing device_path")
|
||||
continue
|
||||
if not variable:
|
||||
warnings.append("mapping skipped: missing variable for {0}".format(device_path))
|
||||
continue
|
||||
|
||||
try:
|
||||
device = _find_device_by_path(proj, device_path)
|
||||
if device is None:
|
||||
warnings.append("device not found: {0}".format(device_path))
|
||||
continue
|
||||
|
||||
param = _find_param(device.device_parameters, param_id=param_id, param_name=param_name)
|
||||
if param is None:
|
||||
warnings.append("param not found: id={0} name={1} on {2}".format(param_id, param_name, device_path))
|
||||
continue
|
||||
|
||||
iom = param.io_mapping
|
||||
if iom is None:
|
||||
warnings.append("no io_mapping on param {0} of {1}".format(param_id or param_name, device_path))
|
||||
continue
|
||||
|
||||
old_var = _safe_str(iom.variable) if iom.variable else ""
|
||||
entry = {
|
||||
"device_path": device_path,
|
||||
"param_id": int(param.id) if hasattr(param, "id") else -1,
|
||||
"param_name": _safe_str(getattr(param, "visible_name", "")),
|
||||
"old_variable": old_var,
|
||||
"new_variable": variable,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
entry["status"] = "would_map"
|
||||
else:
|
||||
iom.variable = variable
|
||||
entry["status"] = "mapped"
|
||||
|
||||
results.append(entry)
|
||||
except Exception as exc:
|
||||
warnings.append("mapping_failed({0}): {1}".format(device_path, exc))
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
proj.save()
|
||||
except Exception as exc:
|
||||
warnings.append("project_save_failed: {0}".format(exc))
|
||||
|
||||
proj.close()
|
||||
|
||||
emit({
|
||||
"ok": True,
|
||||
"action": "write_io_mapping",
|
||||
"project_path": pp,
|
||||
"dry_run": dry_run,
|
||||
"results": results,
|
||||
"result_count": len(results),
|
||||
"warnings": warnings,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
208
python_scripting/windows_codesys_agent/scripts/write_pou.py
Normal file
208
python_scripting/windows_codesys_agent/scripts/write_pou.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python
|
||||
"""Write/update textual objects (POU, FB, GVL, NVL, struct, program) in a CODESYS project."""
|
||||
|
||||
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):
|
||||
"""Walk slash-separated path from project root to find the target object."""
|
||||
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:
|
||||
raise RuntimeError("Object not found at '{0}' (segment {1} of '{2}')".format(
|
||||
part, i, path_str))
|
||||
current = found
|
||||
return current
|
||||
|
||||
|
||||
def _find_by_name(root, name):
|
||||
"""Use project.find() with recursive search."""
|
||||
results = root.find(name, True)
|
||||
if not results:
|
||||
raise RuntimeError("Object '{0}' not found in project.".format(name))
|
||||
if len(results) > 1:
|
||||
paths = []
|
||||
for r in results:
|
||||
try:
|
||||
paths.append(_name_of(r))
|
||||
except Exception:
|
||||
paths.append("?")
|
||||
raise RuntimeError("Multiple objects named '{0}': {1}. Use object_path instead.".format(
|
||||
name, ", ".join(paths)))
|
||||
return results[0]
|
||||
|
||||
|
||||
def _set_text(doc, new_text):
|
||||
"""Replace full text of a ScriptTextDocument."""
|
||||
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 main():
|
||||
payload = load_payload()
|
||||
project_path = payload.get("project_path")
|
||||
if not project_path:
|
||||
raise ValueError("project_path is required.")
|
||||
|
||||
updates = payload.get("updates", [])
|
||||
if not updates:
|
||||
single = {}
|
||||
for key in ("object_path", "object_name", "declaration", "implementation"):
|
||||
if key in payload:
|
||||
single[key] = payload[key]
|
||||
if single:
|
||||
updates = [single]
|
||||
|
||||
if not updates:
|
||||
raise ValueError("Provide 'updates' list or single object_path/object_name + declaration/implementation.")
|
||||
|
||||
dry_run = bool(payload.get("dry_run", False))
|
||||
|
||||
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 = []
|
||||
|
||||
for upd in updates:
|
||||
obj_path = upd.get("object_path", "")
|
||||
obj_name = upd.get("object_name", "")
|
||||
decl_text = upd.get("declaration")
|
||||
impl_text = upd.get("implementation")
|
||||
|
||||
if not obj_path and not obj_name:
|
||||
warnings.append("update skipped: no object_path or object_name")
|
||||
continue
|
||||
|
||||
try:
|
||||
if obj_path:
|
||||
obj = _find_by_path(proj, obj_path)
|
||||
else:
|
||||
obj = _find_by_name(proj, obj_name)
|
||||
|
||||
name = _name_of(obj)
|
||||
entry = {
|
||||
"name": name,
|
||||
"object_path": obj_path or obj_name,
|
||||
"has_declaration": getattr(obj, "has_textual_declaration", False),
|
||||
"has_implementation": getattr(obj, "has_textual_implementation", False),
|
||||
"declaration_updated": False,
|
||||
"implementation_updated": False,
|
||||
}
|
||||
|
||||
if decl_text is not None:
|
||||
if not getattr(obj, "has_textual_declaration", False):
|
||||
warnings.append("{0}: object has no textual declaration".format(name))
|
||||
elif dry_run:
|
||||
entry["declaration_updated"] = "would_update"
|
||||
else:
|
||||
_set_text(obj.textual_declaration, decl_text)
|
||||
entry["declaration_updated"] = True
|
||||
|
||||
if impl_text is not None:
|
||||
if not getattr(obj, "has_textual_implementation", False):
|
||||
warnings.append("{0}: object has no textual implementation".format(name))
|
||||
elif dry_run:
|
||||
entry["implementation_updated"] = "would_update"
|
||||
else:
|
||||
_set_text(obj.textual_implementation, impl_text)
|
||||
entry["implementation_updated"] = True
|
||||
|
||||
results.append(entry)
|
||||
except Exception as exc:
|
||||
warnings.append("update_failed({0}): {1}".format(obj_path or obj_name, exc))
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
proj.save()
|
||||
except Exception as exc:
|
||||
warnings.append("project_save_failed: {0}".format(exc))
|
||||
|
||||
proj.close()
|
||||
|
||||
emit({
|
||||
"ok": True,
|
||||
"action": "write_pou",
|
||||
"project_path": pp,
|
||||
"dry_run": dry_run,
|
||||
"results": results,
|
||||
"result_count": len(results),
|
||||
"warnings": warnings,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user