Поправил скрипт, поправил неточности в библиотеке
This commit is contained in:
+244
-50
@@ -1,7 +1,22 @@
|
||||
# encoding:utf-8
|
||||
# export_plc_src.py
|
||||
# Экспорт POU / GVL / DUT из открытого проекта CODESYS в текстовые файлы
|
||||
# для хранения в git и работы с Claude Code.
|
||||
# plc_src/
|
||||
# ├── PLC1/
|
||||
# │ ├── POU/...
|
||||
# │ ├── GVL/...
|
||||
# │ └── DUT/...
|
||||
# ├── PLC2/
|
||||
# │ ├── POU/...
|
||||
# │ ├── GVL/...
|
||||
# │ └── DUT/...
|
||||
# └── _common/
|
||||
# │ ├── POU/...
|
||||
# │ ├── GVL/...
|
||||
# │ └── DUT/..
|
||||
#
|
||||
# Методы (METHOD) и действия (ACTION) не создают отдельных файлов --
|
||||
# они дописываются в конец файла того POU/FB, в котором были созданы
|
||||
#
|
||||
# Запуск:
|
||||
# 1) Внутри CODESYS: Tools -> Scripting -> Execute Script File -> выбрать этот файл
|
||||
@@ -10,8 +25,6 @@
|
||||
# --Profile="CODESYS V3.5 SP17 Patch 3" --runscript="export_plc_src.py" ^
|
||||
# --project="D:\path\to\ТвойПроект.project"
|
||||
#
|
||||
# Проверено для установки: CODESYS V3.5 SP17 Patch 3 (32-bit)
|
||||
#
|
||||
# По умолчанию папка plc_src создаётся автоматически рядом с файлом .project.
|
||||
# Если авто-определение не сработает, впиши путь вручную в MANUAL_OUTPUT_ROOT ниже.
|
||||
|
||||
@@ -19,9 +32,31 @@ from __future__ import print_function
|
||||
import os
|
||||
import codecs
|
||||
import re
|
||||
import datetime
|
||||
|
||||
PRAGMA_RE = re.compile(r"^\s*\{[^}]*\}\s*")
|
||||
|
||||
# Единая метка времени на весь запуск экспорта (одинаковая во всех файлах).
|
||||
EXPORT_TIMESTAMP = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Имена служебных узлов дерева, которые не нужно показывать в поле Path
|
||||
# (сравнение без учёта регистра). Сами узлы всё равно обходятся -- пропускаются
|
||||
# только их имена при формировании пути.
|
||||
EXCLUDED_PATH_NAMES = set(["application", "plc logic"])
|
||||
|
||||
# Закрывающие ключевые слова, которые CODESYS не включает ни в textual_declaration,
|
||||
# ни в textual_implementation -- их нужно дописывать вручную, чтобы файл был
|
||||
# синтаксически полным ST-блоком.
|
||||
END_KEYWORDS = {
|
||||
"PROGRAM": "END_PROGRAM",
|
||||
"FUNCTION_BLOCK": "END_FUNCTION_BLOCK",
|
||||
"FUNCTION": "END_FUNCTION",
|
||||
"INTERFACE": "END_INTERFACE",
|
||||
"METHOD": "END_METHOD",
|
||||
"PROPERTY": "END_PROPERTY",
|
||||
"ACTION": "END_ACTION",
|
||||
}
|
||||
|
||||
|
||||
def strip_leading_pragmas(text):
|
||||
"""Убирает ведущие блоки вида {attribute 'qualified_only'} перед проверкой ключевого слова."""
|
||||
@@ -43,8 +78,6 @@ def get_project_file_path(proj):
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
# Фолбэк: вытащить путь из текстового представления объекта,
|
||||
# например "Project(Project=0, stPath=C:\...\*.project)"
|
||||
try:
|
||||
s = str(proj)
|
||||
m = re.search(r"stPath=(.*?)\)", s)
|
||||
@@ -71,37 +104,148 @@ def get_declaration_text(obj):
|
||||
return None
|
||||
|
||||
|
||||
def get_text(obj, declaration_text):
|
||||
"""Собирает декларацию + реализацию (если есть) в единый текст."""
|
||||
text = declaration_text or ""
|
||||
def has_implementation(obj):
|
||||
try:
|
||||
if hasattr(obj, "has_textual_implementation") and obj.has_textual_implementation:
|
||||
text += "\r\n(*----- IMPLEMENTATION -----*)\r\n"
|
||||
text += obj.textual_implementation.text
|
||||
return bool(getattr(obj, "has_textual_implementation", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_implementation_text(obj):
|
||||
try:
|
||||
return obj.textual_implementation.text
|
||||
except Exception as e:
|
||||
text += "(* ошибка чтения implementation: %s *)\r\n" % e
|
||||
return text
|
||||
return "(* ошибка чтения implementation: %s *)\r\n" % e
|
||||
|
||||
|
||||
def classify(declaration_text):
|
||||
"""Определяет тип объекта по первому ключевому слову декларации."""
|
||||
if not declaration_text:
|
||||
def classify(obj, declaration_text):
|
||||
"""Определяет тип объекта.
|
||||
Если есть декларация -- смотрим по ключевому слову (POU/GVL/DUT/METHOD/ACTION).
|
||||
Если декларации нет, но есть implementation -- это ACTION (у него нет декларации в API)."""
|
||||
if declaration_text:
|
||||
stripped = strip_leading_pragmas(declaration_text.strip()).strip().upper()
|
||||
if stripped.startswith(("PROGRAM", "FUNCTION_BLOCK", "FUNCTION", "INTERFACE")):
|
||||
return "pou"
|
||||
if stripped.startswith(("METHOD", "PROPERTY")):
|
||||
return "method"
|
||||
if stripped.startswith("VAR_GLOBAL"):
|
||||
return "gvl"
|
||||
if stripped.startswith("TYPE"):
|
||||
return "dut"
|
||||
return None
|
||||
stripped = strip_leading_pragmas(declaration_text.strip()).strip().upper()
|
||||
if stripped.startswith(("PROGRAM", "FUNCTION_BLOCK", "FUNCTION", "INTERFACE", "METHOD", "ACTION", "PROPERTY")):
|
||||
return "pou"
|
||||
if stripped.startswith("VAR_GLOBAL"):
|
||||
return "gvl"
|
||||
if stripped.startswith("TYPE"):
|
||||
return "dut"
|
||||
|
||||
if has_implementation(obj):
|
||||
return "action"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def write_file(folder, name, text):
|
||||
path = os.path.join(folder, name + ".st")
|
||||
def get_type_label(kind, declaration_text):
|
||||
"""Возвращает читаемое название типа объекта, например 'FUNCTION_BLOCK', 'METHOD', 'ACTION'."""
|
||||
if kind == "action":
|
||||
return "ACTION"
|
||||
if declaration_text:
|
||||
stripped = strip_leading_pragmas(declaration_text.strip()).strip().upper()
|
||||
for kw in ("FUNCTION_BLOCK", "PROGRAM", "FUNCTION", "INTERFACE", "METHOD", "PROPERTY"):
|
||||
if stripped.startswith(kw):
|
||||
return kw
|
||||
if stripped.startswith("VAR_GLOBAL"):
|
||||
return "GVL"
|
||||
if stripped.startswith("TYPE"):
|
||||
return "DUT"
|
||||
return kind.upper() if kind else "UNKNOWN"
|
||||
|
||||
|
||||
def close_block(text, type_label):
|
||||
"""Дописывает END_PROGRAM / END_FUNCTION_BLOCK / END_METHOD / END_ACTION и т.п.,
|
||||
если это применимо к данному типу объекта."""
|
||||
kw = END_KEYWORDS.get(type_label)
|
||||
if not kw:
|
||||
return text
|
||||
if not text.endswith("\n"):
|
||||
text += "\r\n"
|
||||
text += kw + "\r\n"
|
||||
return text
|
||||
|
||||
|
||||
def build_pou_text(obj, declaration_text, type_label):
|
||||
"""Декларация + implementation (если есть) + закрывающее END_*."""
|
||||
text = declaration_text or ""
|
||||
if has_implementation(obj):
|
||||
text += "\r\n(*----- IMPLEMENTATION -----*)\r\n"
|
||||
text += get_implementation_text(obj)
|
||||
text = close_block(text, type_label)
|
||||
return text
|
||||
|
||||
|
||||
def build_action_text(obj, name):
|
||||
"""У Action нет declaration в API -- собираем заголовок 'ACTION Имя :' вручную,
|
||||
затем тело и закрывающее END_ACTION."""
|
||||
text = "ACTION %s :\r\n" % name
|
||||
text += get_implementation_text(obj)
|
||||
text = close_block(text, "ACTION")
|
||||
return text
|
||||
|
||||
|
||||
def build_header(project_name, device_name, path_parts, obj_name, type_label):
|
||||
"""Заголовок-паспорт файла:
|
||||
|
||||
(*
|
||||
===============================================================================
|
||||
Project : MyProject
|
||||
Device : PLC
|
||||
Path : Application/Logic/Motion
|
||||
Name : FB_Axis
|
||||
Type : FUNCTION_BLOCK
|
||||
Exported : 2026-07-30 14:32:18
|
||||
===============================================================================
|
||||
*)
|
||||
"""
|
||||
rel_path = "/".join(path_parts) if path_parts else "-"
|
||||
sep = "=" * 79
|
||||
lines = [
|
||||
"(*",
|
||||
sep,
|
||||
" Project : %s" % project_name,
|
||||
" Device : %s" % device_name,
|
||||
" Path : %s" % rel_path,
|
||||
" Name : %s" % obj_name,
|
||||
" Type : %s" % type_label,
|
||||
" Exported : %s" % EXPORT_TIMESTAMP,
|
||||
sep,
|
||||
"*)",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
return "\r\n".join(lines)
|
||||
|
||||
|
||||
def write_new_file(folder, path_parts, obj_name, text, project_name, device_name, type_label):
|
||||
"""path_parts -- путь БЕЗ имени самого объекта (папки родителей).
|
||||
Создаёт вложенные папки и записывает файл с заголовком-паспортом."""
|
||||
sub_parts = [sanitize_folder_name(p) for p in path_parts]
|
||||
sub_folder = os.path.join(folder, *sub_parts) if sub_parts else folder
|
||||
if not os.path.exists(sub_folder):
|
||||
os.makedirs(sub_folder)
|
||||
file_name = sanitize_folder_name(obj_name)
|
||||
path = os.path.join(sub_folder, file_name + ".st")
|
||||
with codecs.open(path, "w", encoding="utf-8") as f:
|
||||
f.write(build_header(project_name, device_name, path_parts, obj_name, type_label))
|
||||
f.write(text)
|
||||
print(" -> %s" % path)
|
||||
return path
|
||||
|
||||
|
||||
def append_to_file(path, path_parts, obj_name, text, project_name, device_name, type_label):
|
||||
"""path_parts здесь включает имя родительского POU (т.к. метод/action лежит "внутри" него)."""
|
||||
if not path or not os.path.exists(path):
|
||||
return False
|
||||
with codecs.open(path, "a", encoding="utf-8") as f:
|
||||
f.write("\r\n")
|
||||
f.write(build_header(project_name, device_name, path_parts, obj_name, type_label))
|
||||
f.write(text)
|
||||
print(" -> дописано в %s (%s: %s)" % (path, type_label, obj_name))
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_folder_name(name):
|
||||
@@ -132,34 +276,78 @@ def is_device_node(obj):
|
||||
return False
|
||||
|
||||
|
||||
def walk(node, dirs, exported):
|
||||
def walk(node, dirs, exported, project_name, device_name, current_path=None, parent_pou_path=None):
|
||||
"""current_path -- список имён родителей от корня устройства (без учёта device_name самого),
|
||||
накапливается по мере рекурсивного спуска по реальному дереву проекта -- поэтому Path
|
||||
строится из фактической структуры, а не из строки, которую отдаёт CODESYS API."""
|
||||
if current_path is None:
|
||||
current_path = []
|
||||
|
||||
for child in node.get_children(False):
|
||||
name = safe_name(child)
|
||||
decl = get_declaration_text(child)
|
||||
kind = classify(decl)
|
||||
kind = classify(child, decl)
|
||||
type_label = get_type_label(kind, decl)
|
||||
|
||||
next_parent_path = parent_pou_path
|
||||
if name.strip().lower() in EXCLUDED_PATH_NAMES:
|
||||
child_path = current_path # не добавляем служебное имя в путь
|
||||
else:
|
||||
child_path = current_path + [name] # путь для рекурсии в детей ЭТОГО child'а
|
||||
|
||||
if kind == "pou":
|
||||
print("POU: %s" % name)
|
||||
write_file(dirs["pou"], name, get_text(child, decl))
|
||||
print("POU: %s/%s" % ("/".join(current_path), name))
|
||||
text = build_pou_text(child, decl, type_label)
|
||||
path = write_new_file(dirs["pou"], current_path, name, text,
|
||||
project_name, device_name, type_label)
|
||||
exported["pou"] += 1
|
||||
next_parent_path = path
|
||||
|
||||
elif kind == "method":
|
||||
print("METHOD: %s/%s" % ("/".join(current_path), name))
|
||||
text = build_pou_text(child, decl, type_label)
|
||||
if parent_pou_path and append_to_file(parent_pou_path, current_path, name, text,
|
||||
project_name, device_name, type_label):
|
||||
exported["method"] += 1
|
||||
else:
|
||||
write_new_file(dirs["pou"], current_path, name, text,
|
||||
project_name, device_name, type_label)
|
||||
exported["method"] += 1
|
||||
|
||||
elif kind == "action":
|
||||
print("ACTION: %s/%s" % ("/".join(current_path), name))
|
||||
text = build_action_text(child, name)
|
||||
if parent_pou_path and append_to_file(parent_pou_path, current_path, name, text,
|
||||
project_name, device_name, type_label):
|
||||
exported["action"] += 1
|
||||
else:
|
||||
write_new_file(dirs["pou"], current_path, name, text,
|
||||
project_name, device_name, type_label)
|
||||
exported["action"] += 1
|
||||
|
||||
elif kind == "gvl":
|
||||
print("GVL: %s" % name)
|
||||
write_file(dirs["gvl"], name, get_text(child, decl))
|
||||
print("GVL: %s/%s" % ("/".join(current_path), name))
|
||||
write_new_file(dirs["gvl"], current_path, name, decl or "",
|
||||
project_name, device_name, type_label)
|
||||
exported["gvl"] += 1
|
||||
|
||||
elif kind == "dut":
|
||||
print("DUT: %s" % name)
|
||||
write_file(dirs["dut"], name, get_text(child, decl))
|
||||
print("DUT: %s/%s" % ("/".join(current_path), name))
|
||||
write_new_file(dirs["dut"], current_path, name, decl or "",
|
||||
project_name, device_name, type_label)
|
||||
exported["dut"] += 1
|
||||
|
||||
else:
|
||||
exported["skipped"] += 1
|
||||
|
||||
# рекурсивно обходим детей (для FB с методами, папок и т.д.)
|
||||
walk(child, dirs, exported)
|
||||
# рекурсивно обходим детей (методы/actions FB, вложенные папки и т.д.),
|
||||
# передавая накопленный путь и (если только что создан POU) новый parent_pou_path
|
||||
walk(child, dirs, exported, project_name, device_name, child_path, next_parent_path)
|
||||
|
||||
|
||||
# ==== НАСТРОЙКА ====
|
||||
# Оставь пустой строкой "" чтобы папка plc_src создавалась автоматически
|
||||
# рядом с файлом .project. Укажи путь вручную только если авто-определение
|
||||
# Оставить пустой строкой "" чтобы папка plc_src создавалась автоматически
|
||||
# рядом с файлом .project. Указать путь вручную только если авто-определение
|
||||
# не сработает (см. вывод скрипта при запуске).
|
||||
MANUAL_OUTPUT_ROOT = r""
|
||||
# =====================================================
|
||||
@@ -169,11 +357,17 @@ proj = projects.primary
|
||||
if proj is None:
|
||||
print("ОШИБКА: нет открытого проекта. Открой .project перед запуском скрипта.")
|
||||
else:
|
||||
project_file = get_project_file_path(proj)
|
||||
|
||||
if project_file:
|
||||
project_name = os.path.splitext(os.path.basename(project_file))[0]
|
||||
else:
|
||||
project_name = "UnknownProject"
|
||||
|
||||
if MANUAL_OUTPUT_ROOT:
|
||||
OUTPUT_ROOT = MANUAL_OUTPUT_ROOT
|
||||
print("Папка вывода задана вручную: %s" % OUTPUT_ROOT)
|
||||
else:
|
||||
project_file = get_project_file_path(proj)
|
||||
if project_file:
|
||||
project_dir = os.path.dirname(project_file)
|
||||
OUTPUT_ROOT = os.path.join(project_dir, "plc_src")
|
||||
@@ -185,31 +379,31 @@ else:
|
||||
print("Задай путь вручную в переменной MANUAL_OUTPUT_ROOT и запусти снова.")
|
||||
|
||||
if OUTPUT_ROOT:
|
||||
exported = {"pou": 0, "gvl": 0, "dut": 0, "skipped": 0}
|
||||
exported = {"pou": 0, "gvl": 0, "dut": 0, "method": 0, "action": 0, "skipped": 0}
|
||||
|
||||
print("--- Экспорт в %s ---" % OUTPUT_ROOT)
|
||||
print("--- Экспорт проекта '%s' в %s ---" % (project_name, OUTPUT_ROOT))
|
||||
|
||||
top_children = proj.get_children(False)
|
||||
device_nodes = [c for c in top_children if is_device_node(c)]
|
||||
|
||||
if device_nodes:
|
||||
for device in device_nodes:
|
||||
dev_name = sanitize_folder_name(safe_name(device))
|
||||
print("=== Устройство: %s ===" % dev_name)
|
||||
dev_dirs = make_dirs(os.path.join(OUTPUT_ROOT, dev_name))
|
||||
walk(device, dev_dirs, exported)
|
||||
device_name = safe_name(device)
|
||||
dev_folder = sanitize_folder_name(device_name)
|
||||
print("=== Устройство: %s ===" % device_name)
|
||||
dev_dirs = make_dirs(os.path.join(OUTPUT_ROOT, dev_folder))
|
||||
walk(device, dev_dirs, exported, project_name, device_name)
|
||||
|
||||
# на случай текстовых объектов вне дерева устройств (редко, но на всякий случай)
|
||||
other_dirs = make_dirs(os.path.join(OUTPUT_ROOT, "_common"))
|
||||
for child in top_children:
|
||||
if not is_device_node(child):
|
||||
walk(child, other_dirs, exported)
|
||||
walk(child, other_dirs, exported, project_name, "-")
|
||||
else:
|
||||
# устройств не нашлось (или is_device не сработал) -- работаем как раньше, без разбивки
|
||||
print("Устройства верхнего уровня не найдены, экспорт без разбивки по папкам.")
|
||||
dirs = make_dirs(OUTPUT_ROOT)
|
||||
walk(proj, dirs, exported)
|
||||
walk(proj, dirs, exported, project_name, "-")
|
||||
|
||||
print("--- Готово: POU=%d, GVL=%d, DUT=%d, пропущено=%d ---" % (
|
||||
exported["pou"], exported["gvl"], exported["dut"], exported["skipped"]
|
||||
print("--- Готово: POU=%d, METHOD=%d, ACTION=%d, GVL=%d, DUT=%d, пропущено=%d ---" % (
|
||||
exported["pou"], exported["method"], exported["action"],
|
||||
exported["gvl"], exported["dut"], exported["skipped"]
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user