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:
2026-05-24 15:47:21 +03:00
parent acdd740d01
commit bedc5d0628
55 changed files with 11120 additions and 3 deletions

View 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)