0
0
Fork 0
Codesys-MCP-SP21-plus/src/scripts/watcher.py
Karstein Phobic Nyvold Kvistad f1ea6e446f fix(watcher): use ASCII-only comments/log strings (IronPython 2.7 syntax error)
0.4.1 used em-dashes (--) in three log strings and one docstring line.
IronPython 2.7 (CODESYS scripting host) rejects non-ASCII bytes in a
source file unless an explicit encoding declaration (PEP 263) is present:

  Microsoft.Scripting.SyntaxErrorException: Non-ASCII character '\xe2' in
  file ...\watcher.py on line 225, but no encoding declared

Replaced em-dashes with ASCII '--' and bumped WATCHER_VERSION to 0.4.2.
File is now ASCII-clean throughout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:10:21 +02:00

265 lines
9.4 KiB
Python

"""
Persistent watcher script for CODESYS IPC.
Runs inside CODESYS via --runscript on the primary (UI) thread.
Polls a commands/ directory and executes each command directly on the
primary thread (no marshalling). Yields to the IDE between polls via
``system.delay()``, which serves the message loop and keeps the UI
interactive.
Why no background thread? CODESYS V3.5 SP21+ removed
``system.execute_on_primary_thread()``, the API older versions of this
watcher used to marshal work from a .NET background thread back to the
UI thread. The single-thread design here works on SP19, SP21, and SP22+.
{IPC_BASE_DIR} is interpolated by Node.js before launch.
"""
import sys
import os
import time
import traceback
import json
# --- Configuration ---
IPC_BASE_DIR = r"{IPC_BASE_DIR}"
COMMANDS_DIR = os.path.join(IPC_BASE_DIR, "commands")
RESULTS_DIR = os.path.join(IPC_BASE_DIR, "results")
POLL_INTERVAL = 50 # milliseconds
WATCHER_VERSION = "0.4.2"
# --- Error capture file (written before anything else can fail) ---
_ERROR_FILE = os.path.join(IPC_BASE_DIR, "watcher_error.txt")
def _write_error(msg):
try:
with open(_ERROR_FILE, "a") as f:
f.write("[%f] %s\n" % (time.time(), msg))
except:
pass
try:
# --- Ensure directories exist ---
if not os.path.exists(COMMANDS_DIR):
os.makedirs(COMMANDS_DIR)
if not os.path.exists(RESULTS_DIR):
os.makedirs(RESULTS_DIR)
# --- Atomic file write helper ---
def atomic_write(file_path, content):
tmp_path = file_path + ".tmp"
with open(tmp_path, "w") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
if os.path.exists(file_path):
os.remove(file_path)
os.rename(tmp_path, file_path)
# --- Write ready signal EARLY ---
ready_path = os.path.join(IPC_BASE_DIR, "ready.signal")
info = {
"version": WATCHER_VERSION,
"python_version": sys.version,
"platform": sys.platform,
"ipc_dir": IPC_BASE_DIR,
"timestamp": time.time(),
"pid": os.getpid(),
}
atomic_write(ready_path, json.dumps(info, indent=2))
print("[WATCHER] Ready signal written to %s" % ready_path)
# --- Import scripting engine ---
_write_error("About to import scriptengine")
import scriptengine as se
_write_error("scriptengine imported OK")
# --- File-based logging ---
_LOG_FILE = os.path.join(IPC_BASE_DIR, "watcher.log")
def _log(msg):
try:
with open(_LOG_FILE, "a") as f:
f.write("[%f] %s\n" % (time.time(), msg))
except:
pass
# --- Output Capture ---
class OutputCapture:
def __init__(self):
self._buffer = []
def write(self, s):
self._buffer.append(str(s))
def writelines(self, lines):
self._buffer.extend([str(l) for l in lines])
def flush(self):
pass
def getvalue(self):
return ''.join(self._buffer)
def execute_script(script_code, request_id):
"""Execute script_code synchronously on the current (primary) thread.
Returns the result dict to be written to results/."""
success = False
output = ""
error = ""
old_stdout = sys.stdout
old_stderr = sys.stderr
capture = OutputCapture()
sys.stdout = capture
sys.stderr = capture
try:
exec_globals = {
'__builtins__': __builtins__,
'sys': sys,
'os': os,
'time': time,
'traceback': traceback,
'shutil': __import__('shutil'),
}
exec(script_code, exec_globals)
output = capture.getvalue()
if "SCRIPT_ERROR" in output:
success = False
error = "Script reported error via SCRIPT_ERROR marker"
elif "SCRIPT_SUCCESS" in output:
success = True
else:
success = True
except SystemExit as e:
output = capture.getvalue()
exit_code = e.code
if exit_code is None or exit_code == 0:
success = True
if "SCRIPT_ERROR" in output:
success = False
error = "Script reported error via SCRIPT_ERROR marker"
elif isinstance(exit_code, int):
if "SCRIPT_SUCCESS" in output and "SCRIPT_ERROR" not in output:
success = True
else:
success = False
error = "Script exited with code %s" % exit_code
elif isinstance(exit_code, str):
success = False
error = exit_code
except KeyboardInterrupt:
# User pressed "Cancel this operation" in CODESYS during this command.
# Abort just this command; the watcher loop continues.
output = capture.getvalue()
error = "Aborted by user (Cancel pressed in CODESYS)"
success = False
except Exception as e:
output = capture.getvalue()
error = "%s: %s\n%s" % (type(e).__name__, str(e), traceback.format_exc())
success = False
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
return {
"requestId": request_id,
"success": success,
"output": output,
"error": error,
"timestamp": time.time(),
}
def process_command(command_file):
"""Process a single command file end-to-end on the primary thread."""
command_path = os.path.join(COMMANDS_DIR, command_file)
request_id = command_file.replace(".command.json", "")
result_path = os.path.join(RESULTS_DIR, "%s.result.json" % request_id)
_log("Processing command: %s" % request_id)
try:
with open(command_path, "r") as f:
command_data = json.loads(f.read())
script_path = command_data.get("scriptPath", "")
if not os.path.exists(script_path):
raise IOError("Script file not found: %s" % script_path)
with open(script_path, "r") as f:
script_code = f.read()
except Exception as read_err:
_log("Error reading command: %s" % read_err)
atomic_write(result_path, json.dumps({
"requestId": request_id,
"success": False,
"output": "",
"error": "Read error: %s" % read_err,
"timestamp": time.time(),
}))
_cleanup_command_files(command_path, request_id)
return
result = execute_script(script_code, request_id)
atomic_write(result_path, json.dumps(result))
_log("Result written: success=%s" % result.get("success"))
_cleanup_command_files(command_path, request_id)
def _cleanup_command_files(command_path, request_id):
try:
if os.path.exists(command_path):
os.remove(command_path)
sp = os.path.join(COMMANDS_DIR, "%s.py" % request_id)
if os.path.exists(sp):
os.remove(sp)
except:
pass
def _terminate_requested():
return os.path.exists(os.path.join(IPC_BASE_DIR, "terminate.signal"))
# --- Main loop on the primary thread ---
print("[WATCHER] Starting watcher v%s (single-thread, primary)" % WATCHER_VERSION)
print("[WATCHER] IPC directory: %s" % IPC_BASE_DIR)
print("[WATCHER] Python version: %s" % sys.version)
_log("Watcher main loop entered")
def _safe_delay(ms):
"""Yield via system.delay() but swallow KeyboardInterrupt.
CODESYS injects KeyboardInterrupt into the script when the user
clicks "Click here to CANCEL this operation" in the IDE. The
watcher should keep running across that -- only an explicit
terminate.signal or process kill should stop it.
"""
try:
se.system.delay(ms)
except KeyboardInterrupt:
_log("KeyboardInterrupt during system.delay() - ignored, watcher continues")
while True:
try:
if _terminate_requested():
_log("Terminate signal received")
print("[WATCHER] Terminate signal received, exiting")
break
cmd_files = sorted([
f for f in os.listdir(COMMANDS_DIR)
if f.endswith(".command.json")
])
if cmd_files:
process_command(cmd_files[0])
except KeyboardInterrupt:
_log("KeyboardInterrupt during loop iteration - ignored, watcher continues")
except Exception as e:
_log("Loop error: %s\n%s" % (e, traceback.format_exc()))
# Yield: serves the message loop so the UI stays interactive.
_safe_delay(POLL_INTERVAL)
_log("Watcher main loop exited")
except KeyboardInterrupt:
# Last-resort: a Cancel that fires before the loop is even reached
# (e.g. during scriptengine import or directory setup) should still
# exit quietly without the CODESYS exception dialog.
_write_error("KeyboardInterrupt outside main loop - exiting quietly")
print("[WATCHER] Cancelled by user before main loop; exiting.")
except Exception as _fatal:
_write_error("FATAL: %s\n%s" % (_fatal, traceback.format_exc()))
print("[WATCHER] FATAL ERROR: %s" % _fatal)
traceback.print_exc()