0
0
Fork 0

feat(create_project): optional deviceName arg swaps template default device (#8)

When the host machine doesn't have the template's default device installed
(e.g. the standard project's PLCWinNT target is missing on a fresh SP22 P1
install with only Win V3 x64 available), every project the user creates
ships with 17 compile errors before any code lands. This adds an optional
deviceName argument to create_project that swaps the device on the freshly
opened project.

How:
  - device_repository.get_all_devices(name, None) -> tuple of devices whose
    display name contains the substring (ScriptDeviceRepository.pyi line 377).
    Highest-version match wins.
  - ScriptDeviceObject.update(new_device.device_id) replaces the device
    kind in-place, preserving the Application/POU/library subtree
    (ScriptDeviceObject.pyi line 145).
  - The existing PLC device is located by walking project.get_children(False)
    for the first child whose ScriptDeviceObjectMarker.is_device == True
    (ScriptDeviceObject.pyi line 104).

If deviceName is omitted/empty, behaviour is unchanged (template default).
If deviceName doesn't resolve in the local device repository, the script
fails with an actionable error pointing at Tools > Device Repository so
the caller can confirm the exact display name.

Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
This commit is contained in:
phobicdotno 2026-04-29 10:17:10 +02:00 committed by GitHub
parent 3dcdb34b58
commit a0c66da38c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 178 additions and 39 deletions

View file

@ -1,54 +1,190 @@
import sys, scriptengine as script_engine, os, shutil, time, traceback
# Placeholders
TEMPLATE_PROJECT_PATH = r'{TEMPLATE_PROJECT_PATH}' # Path to Standard.project
PROJECT_FILE_PATH = r'{PROJECT_FILE_PATH}' # Path for the new project (Target Path)
TEMPLATE_PROJECT_PATH = r'{TEMPLATE_PROJECT_PATH}' # Path to Standard.project
PROJECT_FILE_PATH = r'{PROJECT_FILE_PATH}' # Path for the new project (Target Path)
DEVICE_NAME = r'{DEVICE_NAME}' # Optional: substring of the device's display name (e.g. "CODESYS Control Win V3 x64").
# Empty -> keep the template's default device.
# RTFM (helpme-codesys.com + local SP22 P1 stubs):
# - device_repository is injected into the scripting scope as a global
# (`device_repository`) and exposes
# get_all_devices(name: str, source: ScriptRepositorySource = None) -> tuple[ScriptDeviceDescription]
# overload that returns devices whose display name contains `name`.
# ScriptDeviceRepository.pyi line 377.
# - ScriptDeviceDescription.device_id is the DeviceID triple (type, id,
# version) that update()/plug() take. ScriptDeviceDescription.pyi line 43.
# - ScriptDeviceObject.update(device: DeviceId, module: str = None)
# replaces the device kind in-place, preserving children (Application,
# tasks, libraries). ScriptDeviceObject.pyi line 145.
# - ScriptDeviceObjectMarker.is_device tells us which child of the
# project root is the PLC device. ScriptDeviceObject.pyi line 104.
def _find_device_in_project(project):
"""Walk the project's top-level children and return the first object
whose is_device == True. Returns None if no PLC device is plugged."""
try:
children = project.get_children(False)
except Exception as e:
print("DEBUG: project.get_children failed: %s" % e)
return None
for child in children:
try:
if getattr(child, 'is_device', False):
return child
except Exception:
continue
return None
def _resolve_device_by_name(name):
"""Look up the device repository for a device whose display name
matches `name` (substring match per get_all_devices(name, source)
overload). Returns the highest-version match, or None."""
try:
repo = device_repository # noqa: F821 -- injected by scriptengine
except NameError:
print("DEBUG: device_repository global not in scope.")
return None
try:
candidates = repo.get_all_devices(name, None)
except Exception as e:
print("DEBUG: device_repository.get_all_devices(name, None) failed: %s" % e)
return None
if not candidates:
return None
def _version_key(dev):
try:
v = dev.device_id.version
except Exception:
return (0,)
try:
return tuple(int(p) for p in str(v).split('.'))
except Exception:
return (str(v),)
best = None
best_key = None
for d in candidates:
try:
d_name = d.device_info.name
except Exception:
d_name = '?'
k = _version_key(d)
print("DEBUG: candidate device: name='%s', version=%s" % (d_name, k))
if best is None or k > best_key:
best = d
best_key = k
return best
try:
print("DEBUG: Python script create_project (copy from template):")
print("DEBUG: Template Source = %s" % TEMPLATE_PROJECT_PATH)
print("DEBUG: Target Path = %s" % PROJECT_FILE_PATH)
if not PROJECT_FILE_PATH: raise ValueError("Target project file path empty.")
if not TEMPLATE_PROJECT_PATH: raise ValueError("Template project file path empty.")
if not os.path.exists(TEMPLATE_PROJECT_PATH): raise IOError("Template project file not found: %s" % TEMPLATE_PROJECT_PATH)
print("DEBUG: Target Path = %s" % PROJECT_FILE_PATH)
print("DEBUG: Device Name = %s" % (DEVICE_NAME or '<keep template default>'))
if not PROJECT_FILE_PATH:
raise ValueError("Target project file path empty.")
if not TEMPLATE_PROJECT_PATH:
raise ValueError("Template project file path empty.")
if not os.path.exists(TEMPLATE_PROJECT_PATH):
raise IOError("Template project file not found: %s" % TEMPLATE_PROJECT_PATH)
# 1. Copy the template project file to the new location
target_dir = os.path.dirname(PROJECT_FILE_PATH)
if not os.path.exists(target_dir): print("DEBUG: Creating target directory: %s" % target_dir); os.makedirs(target_dir)
# Check if target file already exists
if os.path.exists(PROJECT_FILE_PATH): print("WARN: Target project file already exists, overwriting: %s" % PROJECT_FILE_PATH)
if not os.path.exists(target_dir):
print("DEBUG: Creating target directory: %s" % target_dir)
os.makedirs(target_dir)
if os.path.exists(PROJECT_FILE_PATH):
print("WARN: Target project file already exists, overwriting: %s" % PROJECT_FILE_PATH)
print("DEBUG: Copying '%s' to '%s'..." % (TEMPLATE_PROJECT_PATH, PROJECT_FILE_PATH))
shutil.copy2(TEMPLATE_PROJECT_PATH, PROJECT_FILE_PATH) # copy2 preserves metadata
shutil.copy2(TEMPLATE_PROJECT_PATH, PROJECT_FILE_PATH)
print("DEBUG: File copy complete.")
# 2. Open the newly copied project file
print("DEBUG: Opening the copied project: %s" % PROJECT_FILE_PATH)
# Set flags for silent opening
update_mode = script_engine.VersionUpdateFlags.NoUpdates | script_engine.VersionUpdateFlags.SilentMode
# try:
# update_mode = script_engine.VersionUpdateFlags.NoUpdates | script_engine.VersionUpdateFlags.SilentMode
# except AttributeError:
# print("WARN: VersionUpdateFlags not found, using integer flags for open (1 | 2 = 3).")
# update_mode = 3
project = script_engine.projects.open(PROJECT_FILE_PATH, update_flags=update_mode)
print("DEBUG: script_engine.projects.open returned: %s" % project)
if project:
print("DEBUG: Pausing briefly after open...")
time.sleep(1.0)
if not project:
msg = ("Failed to open project copy %s after copying template %s. "
"projects.open returned None." % (PROJECT_FILE_PATH, TEMPLATE_PROJECT_PATH))
print(msg)
print("SCRIPT_ERROR: %s" % msg)
sys.exit(1)
print("DEBUG: Pausing briefly after open...")
time.sleep(1.0)
device_swapped = False
swapped_to = None
if DEVICE_NAME:
new_device = _resolve_device_by_name(DEVICE_NAME)
if new_device is None:
msg = ("Device '%s' not found in the local device repository. "
"Open the IDE's Device Repository (Tools > Device Repository) "
"to confirm the exact display name, or pass an empty deviceName "
"to keep the template's default device." % DEVICE_NAME)
print("ERROR: %s" % msg)
print("SCRIPT_ERROR: %s" % msg)
sys.exit(1)
existing_device = _find_device_in_project(project)
if existing_device is None:
msg = ("Project '%s' has no top-level PLC device to swap; the "
"template may be empty or non-standard. Cannot apply "
"deviceName='%s'." % (PROJECT_FILE_PATH, DEVICE_NAME))
print("ERROR: %s" % msg)
print("SCRIPT_ERROR: %s" % msg)
sys.exit(1)
try:
print("DEBUG: Explicitly saving project after opening copy...")
project.save();
print("DEBUG: Project save after opening copy succeeded.")
except Exception as save_err:
print("WARN: Explicit save after opening copy failed: %s" % save_err)
# Decide if this is critical - maybe not, but good to know.
print("Project Created from Template Copy at: %s" % PROJECT_FILE_PATH)
print("SCRIPT_SUCCESS: Project copied from template and opened successfully.")
sys.exit(0)
else:
error_message = "Failed to open project copy %s after copying template %s. projects.open returned None." % (PROJECT_FILE_PATH, TEMPLATE_PROJECT_PATH)
print(error_message); print("SCRIPT_ERROR: %s" % error_message); sys.exit(1)
new_dev_id = new_device.device_id
new_dev_info = new_device.device_info
swapped_to = new_dev_info.name
except Exception as e:
msg = "Resolved device for '%s' is missing device_id/device_info: %s" % (DEVICE_NAME, e)
print("ERROR: %s" % msg)
print("SCRIPT_ERROR: %s" % msg)
sys.exit(1)
print("DEBUG: Swapping device '%s' -> '%s' (type=%s id=%s version=%s)" % (
existing_device.get_name(),
swapped_to,
new_dev_id.type,
new_dev_id.id,
new_dev_id.version))
try:
existing_device.update(new_dev_id)
except Exception as e:
detailed = traceback.format_exc()
msg = ("Device swap failed (existing.update(new_id) raised): %s\n%s"
% (e, detailed))
print("ERROR: %s" % msg)
print("SCRIPT_ERROR: %s" % msg)
sys.exit(1)
device_swapped = True
print("DEBUG: Device swap succeeded.")
try:
print("DEBUG: Saving project after open%s..." % (
" + device swap" if device_swapped else ""))
project.save()
print("DEBUG: Project save succeeded.")
except Exception as save_err:
print("WARN: Save after open failed: %s" % save_err)
print("Project Created from Template Copy at: %s" % PROJECT_FILE_PATH)
if device_swapped:
print("Device set to: %s" % swapped_to)
print("SCRIPT_SUCCESS: Project copied from template%s." % (
" and device swapped" if device_swapped else ""))
sys.exit(0)
except Exception as e:
detailed_error = traceback.format_exc()
error_message = "Error creating project '%s' from template '%s': %s\\n%s" % (PROJECT_FILE_PATH, TEMPLATE_PROJECT_PATH, e, detailed_error)
print(error_message); print("SCRIPT_ERROR: Error copying/opening template: %s" % e); sys.exit(1)
error_message = ("Error creating project '%s' from template '%s': %s\n%s"
% (PROJECT_FILE_PATH, TEMPLATE_PROJECT_PATH, e, detailed_error))
print(error_message)
print("SCRIPT_ERROR: Error copying/opening template: %s" % e)
sys.exit(1)

View file

@ -970,11 +970,12 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
s.tool(
'create_project',
'Creates a new CODESYS project from the standard template.',
"Creates a new CODESYS project from the standard template. Optional `deviceName` -- if supplied, the script swaps the template's default device for the device whose display name matches (substring + highest-version match). Use this when the template's default target (typically PLCWinNT) isn't installed on the host machine and you want, e.g., 'CODESYS Control Win V3 x64' instead. The swap uses ScriptDeviceObject.update() which preserves the Application/POU/library subtree underneath. Empty/omitted = keep the template default.",
{
filePath: z.string().describe("Path where the new project file should be created."),
deviceName: z.string().optional().describe("Optional. Substring of the device display name (e.g. 'CODESYS Control Win V3 x64'). Highest-version match wins. Omit to keep the template's default device."),
},
async (args: { filePath: string }) => {
async (args: { filePath: string; deviceName?: string }) => {
const absPath = path.normalize(
path.isAbsolute(args.filePath) ? args.filePath : path.join(workspaceDir, args.filePath)
);
@ -1009,9 +1010,11 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
const script = scriptManager.prepareScript('create_project', {
PROJECT_FILE_PATH: absPath,
TEMPLATE_PROJECT_PATH: templatePath,
DEVICE_NAME: (args.deviceName ?? '').trim(),
});
const result = await executor.executeScript(script);
return await formatModifyingResponse(result, `Project created from template: ${absPath}`, absPath, mirrorCtx);
const deviceNote = args.deviceName ? ` (device set to '${args.deviceName.trim()}')` : '';
return await formatModifyingResponse(result, `Project created from template: ${absPath}${deviceNote}`, absPath, mirrorCtx);
}
);