# -*- coding: utf-8 -*-
"""MCP-сервер: каталог ДКС + проект RamCube (XML).
Инструменты:
dkc_search — поиск аппаратов в каталоге ДКС (библиотека DKC_data.mdf)
dkc_device — полные параметры позиции + габарит
ramcube_project_read — сводка проекта RamCube из XML
ramcube_apparatus_replace — замена аппаратов в схеме XML (оба набора свойств)
ramcube_set_project — параметры проекта (IP, секционирование, ввод кабелей, Iном, Iкз)
Запуск: python server.py (stdio)
"""
from __future__ import annotations
import os
import re
import subprocess
import xml.etree.ElementTree as ET
from typing import Any, Literal
import pyodbc
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("dkc-ramcube")
def _find_localdb() -> str:
"""SqlLocalDB.exe: берём из переменной окружения или ищем старшую версию."""
env = os.environ.get("DKC_LOCALDB")
if env:
return env
base = r"C:\Program Files\Microsoft SQL Server"
found = []
for name in os.listdir(base) if os.path.isdir(base) else []:
if not name.isdigit():
continue
p = os.path.join(base, name, "Tools", "Binn", "SqlLocalDB.exe")
if os.path.exists(p):
found.append((int(name), p))
if not found:
raise RuntimeError("SqlLocalDB.exe не найден: задайте DKC_LOCALDB")
return max(found)[1]
LOCALDB = _find_localdb()
INSTANCE = os.environ.get("DKC_LOCALDB_INSTANCE", "MSSQLLocalDB")
DB = os.environ.get("DKC_DB", "DKC_cat_ro_v44")
CONN_STR = os.environ.get("DKC_CONN", "") # готовые строки подключения: перебивает LocalDB
# id параметров каталога (проверено по базе 4.4)
P_IN, P_IN_R, P_ICU, P_CODE, P_POLES = 2, 3, 8, 9, 10
P_VENDOR, P_CAT_TYPE, P_DESIGN = 11, 12, 43
# модели 3D по семействам (пути внутри <RAMcube>\dwg)
MODELS = {
"NW08-NW25": r".\Schneider Electric\Drawings\Models\3D\ACB\NW08-NW25 MTZ2 (630-2000) (W) 3p_3D.dwg",
"NW32-NW40": r".\Schneider Electric\Drawings\Models\3D\ACB\NW32-NW40 MTZ2 (3200-4000А) (W) 3p_3D.dwg",
}
# ---------------------------------------------------------------- БД каталога
_conn: pyodbc.Connection | None = None
def _db() -> pyodbc.Connection:
"""Подключение к базе каталога через pipe LocalDB (драйвер {SQL Server})."""
global _conn
if _conn is not None:
return _conn
# ODBC-драйвер {SQL Server} раскрывает %WINDIR%: в урезанном окружении stdio-процесса
# переменных может не быть — иначе ошибка IM003 «не найден SQLSRV32.dll».
os.environ.setdefault("SystemRoot", r"C:\Windows")
os.environ.setdefault("WINDIR", r"C:\Windows")
if CONN_STR:
_conn = pyodbc.connect(CONN_STR, timeout=30)
return _conn
subprocess.run([LOCALDB, "start", INSTANCE], capture_output=True, timeout=90)
info = subprocess.run([LOCALDB, "info", INSTANCE], capture_output=True, text=True, timeout=60).stdout
m = re.search(r"(np:\\.*?tsql\\query)", info)
if not m:
raise RuntimeError("LocalDB не запущен: нет instance pipe name")
_conn = pyodbc.connect(
"DRIVER={SQL Server};SERVER=%s;DATABASE=%s;Trusted_Connection=yes;" % (m.group(1), DB),
timeout=30,
)
return _conn
def _params(device_id: int) -> dict[int, str]:
cur = _db().cursor()
q = """SELECT deviceValueParameter, CAST(deviceValueContent AS nvarchar(400))
FROM tblDeviceValues WHERE deviceValueDevice = ?"""
# deviceValueContent — тип sql_variant, pyodbc его не читает (ODBC type -150): только через CAST
return {int(pid): ("" if val is None else str(val)) for pid, val in cur.execute(q, device_id)}
def _bounds(device_id: int) -> dict[str, float] | None:
cur = _db().cursor()
q = """SELECT unitBoundMinX, unitBoundMaxX, unitBoundMinY, unitBoundMaxY,
unitBoundMinZ, unitBoundMaxZ
FROM tblDrawingUnits WHERE unitDevice = ?"""
row = cur.execute(q, device_id).fetchone()
if not row:
return None
return {"width": round(row[1] - row[0], 1), "depth": round(row[3] - row[2], 1), "height": round(row[5] - row[4], 1)}
def _series(code: str) -> str | None:
"""Семейство ACB по коду: ACB NW08..NW25 / ACB NW32..NW40."""
m = re.search(r"NW(\d+)", code or "")
if not m:
return None
n = int(m.group(1))
if 8 <= n <= 25:
return "NW08-NW25"
if 32 <= n <= 40:
return "NW32-NW40"
return None
# ---------------------------------------------------------------- инструменты
@mcp.tool()
def dkc_search(query: str = "", in_min: int | None = None, in_max: int | None = None,
with_geometry_only: bool = False, limit: int = 20) -> list[dict[str, Any]]:
"""Поиск аппаратов в каталоге ДКС.
query — подстрока артикула/кода (например 'NW40', 'MNX250', 'MD63N');
in_min/in_max — фильтр по номинальному току In, А;
with_geometry_only — только позиции с 3D-геометрией (те, что встанут в сборку).
"""
cur = _db().cursor()
# без alias и ORDER BY: legacy-драйвер {SQL Server} + pyodbc ломается на описании колонок
rows = cur.execute("SELECT deviceId, Code FROM tblDevices").fetchall()
if query:
q = query.lower()
rows = [r for r in rows if q in (r[1] or "").lower()]
rows.sort(key=lambda r: r[1] or "")
out: list[dict[str, Any]] = []
for device_id, code in rows:
pr = _params(device_id)
try:
in_a = int(float(pr.get(P_IN, "") or 0))
except ValueError:
in_a = None
if in_min is not None and (in_a is None or in_a < in_min):
continue
if in_max is not None and (in_a is None or in_a > in_max):
continue
b = _bounds(device_id)
if with_geometry_only and b is None:
continue
out.append({
"device_id": device_id,
"code": pr.get(P_CODE, code),
"catalog_type": pr.get(P_CAT_TYPE, ""),
"in_a": in_a,
"icu_ka": pr.get(P_ICU, ""),
"poles": pr.get(P_POLES, ""),
"design": pr.get(P_DESIGN, ""),
"vendor": pr.get(P_VENDOR, ""),
"bounds_mm": b,
"series": _series(pr.get(P_CODE, code)),
})
if len(out) >= limit:
break
return out
@mcp.tool()
def dkc_device(device_id: int) -> dict[str, Any]:
"""Полные параметры позиции каталога по deviceId (deviceId = elementDevice в XML RamCube)."""
pr = _params(device_id)
cur = _db().cursor()
row = cur.execute("SELECT Code FROM tblDevices WHERE deviceId = ?", device_id).fetchone()
if not row:
raise ValueError("deviceId %s не найден в каталоге" % device_id)
return {
"device_id": device_id,
"code": pr.get(P_CODE, row[0]),
"params": {str(k): v for k, v in sorted(pr.items())},
"bounds_mm": _bounds(device_id),
"series": _series(pr.get(P_CODE, row[0])),
"model_3d": MODELS.get(_series(pr.get(P_CODE, row[0])) or "", None),
}
# ---------------------------------------------------------------- XML проекта
def _load(path: str) -> ET.ElementTree:
if not os.path.exists(path):
raise FileNotFoundError(path)
return ET.parse(path)
def _cab_widths(root: ET.Element) -> list[dict[str, Any]]:
ass = {}
for e in root.find("elementsAssembly") or []:
a = e.attrib
try:
w = float(a.get("elementBoundMaxX", 0)) - float(a.get("elementBoundMinX", 0))
except (TypeError, ValueError):
w = 0.0
ass[a["elementId"]] = (a.get("elementDevice"), w)
inst = {i.attrib["elementInstanceId"]: i.attrib for i in root.find("elementInstancesAssembly") or []}
rows = []
for c in root.find("cabinets") or []:
sig = c.attrib.get("cabinetSignature", "?")
i = inst.get(c.attrib.get("cabinetElementInstance"), {})
dev, w = ass.get(i.get("elementInstanceElement"), ("?", 0.0))
kind = ("cable" if "Кабельн" in sig else "bus" if "Шинн" in sig else
"input" if "Вводн" in sig else "section" if "Секцион" in sig else
"distribution" if "Распредел" in sig else "other")
rows.append({"signature": sig, "device": dev, "width_mm": round(w, 1), "kind": kind})
return rows
@mcp.tool()
def ramcube_project_read(xml_path: str) -> dict[str, Any]:
"""Сводка проекта RamCube из XML: параметры, аппараты схемы, панели, габарит."""
root = _load(xml_path).getroot()
a = root.attrib
dict_props = {r.attrib["propertyProperty"]: r.attrib["propertyName"]
for r in root.find("properties") if "propertyProperty" in r.attrib}
vals: dict[str, dict[str, str]] = {}
for r in root.find("properties"):
x = r.attrib
if "propertyValueInstance" in x:
vals.setdefault(x["propertyValueInstance"], {})[x["propertyValueProperty"]] = x["propertyValueContent"]
elems = {e.attrib["elementId"]: e.attrib for e in root.find("elements")}
apparatus = []
for ins in root.find("elementInstances"):
i = ins.attrib
e = elems.get(i["elementInstanceElement"], {})
pr = vals.get(i["elementInstanceInstance"], {})
apparatus.append({
"instance": i["elementInstanceId"],
"element": i["elementInstanceElement"],
"device": e.get("elementDevice"),
"code": pr.get("9"),
"in_a": pr.get("2"),
"x": i.get("elementInstancePositionX"),
"signature": i.get("elementInstanceSignature", ""),
})
cabs = _cab_widths(root)
total = round(sum(c["width_mm"] for c in cabs), 1)
useful = round(sum(c["width_mm"] for c in cabs if c["kind"] in ("input", "distribution", "section")), 1)
return {
"file": xml_path,
"project": {k: a.get(k) for k in (
"projectName", "projectIP", "projectAmperageNominal", "projectAmperageShortCircuit",
"projectSectioning", "projectCableEntrance", "projectDimensionsHeight", "projectExistsAVR")},
"counts": {t: len(list(root.find(t))) for t in (
"elements", "elementsAssembly", "elementInstances", "elementInstancesAssembly",
"cabinets", "schemes", "buses", "wires")},
"apparatus": apparatus,
"cabinets": cabs,
"length_mm": {"total": total, "useful": useful,
"service": round(total - useful, 1)},
"notes": {"cable_entrance": "1 = сверху, 0 = снизу"},
}
@mcp.tool()
def ramcube_apparatus_replace(xml_in: str, xml_out: str,
replacements: list[dict[str, Any]]) -> dict[str, Any]:
"""Замена аппаратов в схеме XML.
replacements — список: {"element_id": "-174426", "new_device_id": 11919}
(или вместо element_id — "element_device": "11765", тогда меняются все элементы с этим deviceId).
Правятся оба набора свойств (эталон элемента и каждый экземпляр) — иначе RamCube
на загрузке выдаёт «код экземпляра не соответствует элементу».
"""
tree = _load(xml_in)
root = tree.getroot()
elems = {e.attrib["elementId"]: e for e in root.find("elements")}
targets: dict[str, dict[str, str]] = {} # elementId -> props нового аппарата
report = []
for rep in replacements:
new_id = int(rep["new_device_id"])
pr = _params(new_id)
code = pr.get(P_CODE, "")
series = _series(code)
props = {
"9": code,
"2": pr.get(P_IN, ""),
"3": pr.get(P_IN_R, ""),
"8": pr.get(P_ICU, ""),
"12": pr.get(P_CAT_TYPE, ""),
"43": pr.get(P_DESIGN, ""),
}
model = rep.get("model") or MODELS.get(series or "")
matched = []
for eid, el in elems.items():
if rep.get("element_id"):
hit = eid == str(rep["element_id"])
else:
hit = el.attrib.get("elementDevice") == str(rep.get("element_device"))
if not hit:
continue
old = el.attrib.get("elementDevice")
el.attrib["elementDevice"] = str(new_id)
if model:
el.attrib["elementContentFileName"] = model
targets[eid] = props
matched.append({"element_id": eid, "old_device": old, "new_device": str(new_id),
"code": code, "series": series})
report.extend(matched)
if not report:
raise ValueError("ни один элемент не найден по заданным criteria")
# эталон элемента + наборы экземпляров
inst_props: dict[str, dict[str, str]] = {}
for eid, props in targets.items():
ei = elems[eid].attrib.get("elementInstance")
if ei:
inst_props[ei] = props
for ins in root.find("elementInstances"):
eid = ins.attrib.get("elementInstanceElement")
if eid in targets:
inst_props[ins.attrib["elementInstanceInstance"]] = targets[eid]
changed = 0
for rec in root.find("properties"):
x = rec.attrib
inst = x.get("propertyValueInstance")
if inst in inst_props and x.get("propertyValueProperty") in inst_props[inst]:
x["propertyValueContent"] = inst_props[inst][x["propertyValueProperty"]]
changed += 1
tree.write(xml_out, encoding="utf-8", xml_declaration=True)
# валидация: перечитать и сверить коды
check = ET.parse(xml_out).getroot()
cvals: dict[str, dict[str, str]] = {}
for r in check.find("properties"):
x = r.attrib
if "propertyValueInstance" in x:
cvals.setdefault(x["propertyValueInstance"], {})[x["propertyValueProperty"]] = x["propertyValueContent"]
verified = []
for ins in check.find("elementInstances"):
i = ins.attrib
if i.get("elementInstanceElement") in targets:
pr = cvals.get(i["elementInstanceInstance"], {})
verified.append({"instance": i["elementInstanceId"], "code": pr.get("9"), "in_a": pr.get("2"),
"catalog_type": pr.get("12"), "design": pr.get("43")})
return {"out": xml_out, "elements": report, "property_sets": len(inst_props),
"values_changed": changed, "verified_instances": verified}
@mcp.tool()
def ramcube_set_project(xml_path: str, name: str | None = None, ip: str | None = None,
sectioning: Literal["3b", "4b"] | None = None,
cable_entrance: Literal["сверху", "снизу"] | None = None,
in_nominal: int | None = None, ikz_ka: float | None = None,
exists_avr: bool | None = None) -> dict[str, Any]:
"""Параметры проекта RamCube. cable_entrance: 'сверху' = 1, 'снизу' = 0."""
tree = _load(xml_path)
a = tree.getroot().attrib
if name:
a["projectName"] = name
if ip:
a["projectIP"] = str(ip)
if sectioning:
a["projectSectioning"] = sectioning
if cable_entrance:
a["projectCableEntrance"] = "1" if cable_entrance == "сверху" else "0"
if in_nominal:
a["projectAmperageNominal"] = str(in_nominal)
if ikz_ka is not None:
a["projectAmperageShortCircuit"] = str(ikz_ka)
if exists_avr is not None:
a["projectExistsAVR"] = "true" if exists_avr else "false"
tree.write(xml_path, encoding="utf-8", xml_declaration=True)
return {k: tree.getroot().attrib.get(k) for k in (
"projectName", "projectIP", "projectSectioning", "projectCableEntrance",
"projectAmperageNominal", "projectAmperageShortCircuit", "projectExistsAVR")}
if __name__ == "__main__":
mcp.run()