dsh-eda-mcp 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/CONTRIBUTING.md +0 -1
- package/README.md +13 -6
- package/README.zh.md +13 -6
- package/docs/release.md +1 -1
- package/docs/schematic-workflow.md +152 -0
- package/docs/tools.md +13 -9
- package/dsh-eda-bridge-extension-0.4.0.zip +0 -0
- package/jlc-extension/api-bridge.js +1 -1
- package/lib/bridge.mjs +440 -133
- package/lib/client.js +17 -1
- package/lib/index.js +278 -8
- package/package.json +3 -4
- package/src/bridge/index.ts +436 -112
- package/src/bridge/workflow-guide.ts +45 -0
- package/src/client/EdaMcpSettingsCard.tsx +10 -1
- package/src/client/locales.ts +4 -0
- package/src/guidance.ts +4 -0
- package/src/index.ts +45 -2
- package/src/routes.ts +3 -1
- package/src/skills-content.ts +164 -0
- package/src/skills.ts +91 -0
- package/test/skills.test.ts +62 -0
- package/dsh-eda-bridge-extension.zip +0 -0
package/src/bridge/index.ts
CHANGED
|
@@ -16,11 +16,15 @@ import {
|
|
|
16
16
|
TRACK_WIDTH_MIL, VIA_HOLE_ABSOLUTE_MIN_MM, VIA_HOLE_MIN_MM, VIA_SIZES_MM,
|
|
17
17
|
assertNetName, checkNetName, checkTrackWidth, checkVia, classifyNet, milToMm, suggestTrackWidthMil,
|
|
18
18
|
} from './design-rules.ts'
|
|
19
|
+
import { WORKFLOW_GUIDE, WORKFLOW_SUMMARY } from './workflow-guide.ts'
|
|
19
20
|
|
|
20
21
|
const VERSION = '0.4.0'
|
|
21
22
|
const WS_PORT = Number(process.env.DSH_EDA_MCP_WS_PORT ?? 39009)
|
|
22
23
|
const WS_HOST = '127.0.0.1'
|
|
23
24
|
const ALLOW_RAW_API = (process.env.DSH_EDA_MCP_ALLOW_RAW_API ?? '1') !== '0'
|
|
25
|
+
// Automatic layout/routing is opt-in: placing parts and routing by hand is the
|
|
26
|
+
// default working style (visual PNG check beats letting the app rearrange a sheet).
|
|
27
|
+
const ALLOW_AUTO_LAYOUT = (process.env.DSH_EDA_ALLOW_AUTO_LAYOUT ?? '0') === '1'
|
|
24
28
|
const TOOL_TIMEOUT_MS = Number(process.env.DSH_EDA_MCP_TOOL_TIMEOUT_MS ?? 60000)
|
|
25
29
|
|
|
26
30
|
const MCP_UNITS_PER_INCH = 100
|
|
@@ -50,6 +54,15 @@ function safeAreas(paperName) {
|
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/** Decode a text payload that may arrive as UTF-16LE (BOM or interleaved NULs). */
|
|
58
|
+
function decodeTextPayload(value) {
|
|
59
|
+
const text = typeof value === 'string' ? value : ''
|
|
60
|
+
if (text === '') return text
|
|
61
|
+
const looksUtf16 = text.charCodeAt(0) === 0xfeff || (text.length > 3 && text.charCodeAt(1) === 0 && text.charCodeAt(3) === 0)
|
|
62
|
+
if (!looksUtf16) return text
|
|
63
|
+
return Buffer.from(text, 'latin1').toString('utf16le').replace(String.fromCharCode(0xfeff), '')
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
function checkPointInFrame(x, y, paperName, margin = 0) {
|
|
54
67
|
const paper = paperOf(paperName)
|
|
55
68
|
const f = paper.frame
|
|
@@ -264,11 +277,11 @@ const TOOLS = [
|
|
|
264
277
|
}, ['libraryUuid', 'uuid', 'x', 'y']) },
|
|
265
278
|
{ name: 'eda_place_net_flag', description: 'Place a power/ground net flag (VCC/GND etc.) on the active schematic page.', inputSchema: objectSchema({ identification: { type: 'string', enum: ['Power', 'Ground', 'AnalogGround', 'ProtectGround'] }, net: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, mirror: { type: 'boolean' }, paper: { type: 'string' }, allowOutsideFrame: { type: 'boolean' } }, ['identification', 'net', 'x', 'y']) },
|
|
266
279
|
{ name: 'eda_place_net_port', description: 'Place a net port on the active schematic page.', inputSchema: objectSchema({ direction: { type: 'string', enum: ['IN', 'OUT', 'BI'] }, net: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, mirror: { type: 'boolean' }, paper: { type: 'string' }, allowOutsideFrame: { type: 'boolean' } }, ['direction', 'net', 'x', 'y']) },
|
|
267
|
-
{ name: 'eda_get_pins', description: 'List pins of one schematic component. Use the returned primitiveId with eda_place_net_flag_at_pin, eda_place_net_label_at_pin, or eda_set_no_connect.', inputSchema: objectSchema({ componentPrimitiveId: { type: 'string' } }, ['componentPrimitiveId']) },
|
|
280
|
+
{ name: 'eda_get_pins', description: 'List pins of one schematic component in a single round trip, including pinName and pinNumber (no per-pin sch_PrimitivePin.get needed). Use the returned primitiveId with eda_place_net_flag_at_pin, eda_place_net_label_at_pin, or eda_set_no_connect.', inputSchema: objectSchema({ componentPrimitiveId: { type: 'string' } }, ['componentPrimitiveId']) },
|
|
268
281
|
{ name: 'eda_place_net_flag_at_pin', description: 'Place a power/ground net flag at a pin WITHOUT covering the symbol: the pin direction is read from the pin rotation, a stub wire of stubLength MCP units (default 20) is drawn outwards from the pin with the net name, and the flag is placed at its end. Pass offsetX/offsetY to place it manually (that disables the stub) or stubLength=0 for the old on-pin behaviour.', inputSchema: objectSchema({ pinPrimitiveId: { type: 'string' }, identification: { type: 'string', enum: ['Power', 'Ground', 'AnalogGround', 'ProtectGround'] }, net: { type: 'string' }, stubLength: { type: 'number' }, offsetX: { type: 'number' }, offsetY: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, mirror: { type: 'boolean' }, color: { type: 'string' }, lineWidth: { type: 'integer' }, lineType: { type: 'integer' }, paper: { type: 'string' }, allowOutsideFrame: { type: 'boolean' } }, ['pinPrimitiveId', 'identification', 'net']) },
|
|
269
282
|
{ name: 'eda_place_net_label_at_pin', description: 'Place a REAL net label at a pin (sch_PrimitiveAttribute.createNetLabel; falls back to text and reports method) WITHOUT covering the symbol: a stub wire of stubLength MCP units (default 20) is drawn outwards along the pin direction and the label goes at its end. offsetX/offsetY place it manually (disables the stub); stubLength=0 keeps the old on-pin behaviour.', inputSchema: objectSchema({ pinPrimitiveId: { type: 'string' }, net: { type: 'string' }, stubLength: { type: 'number' }, offsetX: { type: 'number' }, offsetY: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, fontSize: { type: 'number' }, color: { type: 'string' }, lineWidth: { type: 'integer' }, lineType: { type: 'integer' }, paper: { type: 'string' }, allowOutsideFrame: { type: 'boolean' } }, ['pinPrimitiveId', 'net']) },
|
|
270
283
|
{ name: 'eda_set_no_connect', description: 'Set or clear the no-connect flag on one or more schematic pins via sch_PrimitivePin.modify.', inputSchema: objectSchema({ primitiveIds: { type: 'array', items: { type: 'string' } }, noConnected: { type: 'boolean' } }, ['primitiveIds']) },
|
|
271
|
-
{ name: 'eda_draw_wire', description: 'Draw a schematic wire (polyline). Coordinates are MCP units (0.01 inch).', inputSchema: objectSchema({ points: { type: 'array', items: { type: 'number' } }, net: { type: 'string' }, color: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] } }, ['points']) },
|
|
284
|
+
{ name: 'eda_draw_wire', description: 'Draw a schematic wire (polyline). Coordinates are MCP units (0.01 inch). IMPORTANT: the net argument only sticks on a floating wire — as soon as an endpoint touches a pin, EDA recomputes the net from connectivity and the name you passed is overwritten (measured: TESTNET merged into LED_A). To join same-named nets across regions use eda_place_net_port or a net label; do not rely on two distantly drawn wires sharing a name.', inputSchema: objectSchema({ points: { type: 'array', items: { type: 'number' } }, net: { type: 'string' }, color: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] } }, ['points']) },
|
|
272
285
|
{ name: 'eda_draw_rectangle', description: 'Draw a rectangle. topLeftY is the UPPER y coordinate (max y); height extends downward. Coordinates are MCP units.', inputSchema: objectSchema({ topLeftX: { type: 'number' }, topLeftY: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' }, cornerRadius: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] }, color: { type: 'string' }, fillColor: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] }, fillStyle: { type: 'string' } }, ['topLeftX', 'topLeftY', 'width', 'height']) },
|
|
273
286
|
{ name: 'eda_draw_functional_box', description: 'Draw a dashed functional-group box around one group of components, with an optional label. Margins and coordinates are MCP units; default margin 1 unit (about 0.254 mm).', inputSchema: objectSchema({ componentIds: { type: 'array', items: { type: 'string' } }, label: { type: 'string' }, margin: { type: 'number' }, color: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] } }) },
|
|
274
287
|
{ name: 'eda_draw_circle', description: 'Draw a circle. Coordinates are MCP units.', inputSchema: objectSchema({ centerX: { type: 'number' }, centerY: { type: 'number' }, radius: { type: 'number' }, color: { type: 'string' }, fillColor: { type: 'string' }, lineWidth: { type: 'integer', minimum: 1, maximum: 10 }, lineType: { type: 'integer', enum: [0, 1, 2, 3] }, fillStyle: { type: 'string' } }, ['centerX', 'centerY', 'radius']) },
|
|
@@ -281,14 +294,18 @@ const TOOLS = [
|
|
|
281
294
|
{ name: 'eda_get_nets', description: 'List nets of the current schematic project. Uses sch_Net.* first; when the schematic is not the active document those APIs return [] and this tool falls back to parsing sch_Netlist.getNetlist, reporting which source was used.', inputSchema: objectSchema({ name: { type: 'string' }, parseNetlist: { type: 'boolean' } }) },
|
|
282
295
|
{ name: 'eda_get_active_document', description: 'Return the document/tab that is currently active (documentType, uuid, tabId, parent project). Most tab-dependent APIs (sch_Net.*, netlist export, DRC) only work when the matching document is active - use eda_open_document to switch.', inputSchema: objectSchema({}) },
|
|
283
296
|
{ name: 'eda_open_document', description: 'Open/activate a document tab by uuid (dmt_EditorControl.openDocument). Works for a schematic page or a PCB, so tab-dependent APIs can run.', inputSchema: objectSchema({ documentUuid: { type: 'string' }, splitScreenId: { type: 'string' } }, ['documentUuid']) },
|
|
284
|
-
{ name: 'eda_get_net_labels', description: 'List
|
|
285
|
-
{ name: 'eda_place_net_label', description: 'Place a
|
|
286
|
-
{ name: '
|
|
287
|
-
{ name: '
|
|
288
|
-
{ name: '
|
|
297
|
+
{ name: 'eda_get_net_labels', description: 'List every label-like primitive on the active schematic page: attribute primitives (netlabel/netport/netflag) PLUS plain texts left by the createNetLabel fallback, so they can be audited and deleted. Reports kind/primitiveId/net per entry.', inputSchema: objectSchema({ parentPrimitiveId: { type: 'string' } }) },
|
|
298
|
+
{ name: 'eda_place_net_label', description: 'Place a net label at x/y. The real API (sch_PrimitiveAttribute.createNetLabel) is probed once per session; when the build never answers, a plain TEXT label is placed instead, which carries no electrical meaning and must be paired with a stub wire. Pass probe:true to re-check.', inputSchema: objectSchema({ x: { type: 'number' }, y: { type: 'number' }, net: { type: 'string' }, fontSize: { type: 'number' }, rotation: { type: 'integer', enum: [0, 90, 180, 270] } }, ['x', 'y', 'net']) },
|
|
299
|
+
{ name: 'eda_place_net_labels', description: 'Place many net labels in ONE call (per-pin or by coordinate) and report done/failed per item: a 133-label schematic used to exceed the caller timeout with no progress. items: [{ pinPrimitiveId, net, stubLength?, offsetX?, offsetY? }] or [{ x, y, net, fontSize? }].', inputSchema: objectSchema({ items: { type: 'array', items: { type: 'object', additionalProperties: true } }, probe: { type: 'boolean' } }, ['items']) },
|
|
300
|
+
{ name: 'eda_run_drc', description: 'Run schematic DRC with includeVerboseError and return one entry per problem (type, message, primitiveId, x/y plus the raw record) instead of bare counts.', inputSchema: objectSchema({ strict: { type: 'boolean' } }) },
|
|
301
|
+
{ name: 'eda_auto_layout', description: 'Run schematic auto-layout (sch_Document.autoLayout). DISABLED by default: automated layout is not used unless the user asks for it. Pass force:true only then.', inputSchema: objectSchema({ uuids: { type: 'array', items: { type: 'string' } }, force: { type: 'boolean' } }) },
|
|
302
|
+
{ name: 'eda_auto_route', description: 'Run schematic auto-routing (sch_Document.autoRouting). DISABLED by default: route by hand unless the user explicitly asks. Pass force:true only then.', inputSchema: objectSchema({ uuids: { type: 'array', items: { type: 'string' } }, force: { type: 'boolean' } }) },
|
|
289
303
|
{ name: 'eda_export_netlist', description: 'Export the schematic netlist as text (default JLCEDA format). Returns null when no schematic is active - check eda_get_active_document / open the schematic page first.', inputSchema: objectSchema({ type: { type: 'string' } }) },
|
|
290
|
-
{ name: 'eda_export_bom', description: 'Export the schematic BOM. csv
|
|
291
|
-
{ name: '
|
|
304
|
+
{ name: 'eda_export_bom', description: 'Export the schematic BOM. csv is decoded from UTF-16 to readable text; xlsx returns a base64 data URL.', inputSchema: objectSchema({ format: { type: 'string', enum: ['csv', 'xlsx'] } }) },
|
|
305
|
+
{ name: 'eda_move_component', description: 'Move/rotate/mirror a schematic component (sch_PrimitiveComponent.modify) without deleting and re-placing it, so the designator and value survive.', inputSchema: objectSchema({ primitiveId: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, rotation: { type: 'number' }, mirror: { type: 'boolean' } }, ['primitiveId']) },
|
|
306
|
+
{ name: 'eda_workflow_guide', description: 'Return the schematic working standard: grid-aligned manual placement, signal flow, when net labels/ports are required (a wire net name is overridden by connectivity), net-label fallback behaviour, batch placement and the verification order. Read it before laying out a sheet.', inputSchema: objectSchema({}) },
|
|
307
|
+
{ name: 'eda_list_api_methods', description: 'List every API method the connected extension accepts (its allow-list). Call it when eda_api_call answers "API method not allowed".', inputSchema: objectSchema({}) },
|
|
308
|
+
{ name: 'eda_delete_primitive', description: 'Delete a schematic primitive by kind and primitive id. Net ports, net flags and net labels are attribute primitives and need kind "netport"/"netflag"/"netlabel"; the text fallback labels are kind "text".', inputSchema: objectSchema({ kind: { type: 'string', enum: ['component', 'wire', 'rectangle', 'circle', 'arc', 'polygon', 'text', 'netport', 'netflag', 'netlabel'] }, primitiveId: { type: 'string' } }, ['kind', 'primitiveId']) },
|
|
292
309
|
{ name: 'eda_create_board', description: 'Create a board linking a schematic and a PCB (dmt_Board.createBoard).', inputSchema: objectSchema({ schematicUuid: { type: 'string' }, pcbUuid: { type: 'string' } }) },
|
|
293
310
|
{ name: 'eda_api_call', description: 'Call an allow-listed JLCEDA Pro extension API method directly.', inputSchema: objectSchema({ method: { type: 'string' }, args: { type: 'array' } }, ['method', 'args']) },
|
|
294
311
|
{ name: 'pcb_get_components', description: 'List PCB components (mil). includePins=true also reads pcb_PrimitiveComponent.getAllPinsByPrimitiveId per component, which is the accurate pad/net source; the fast summary pad list can be stale.', inputSchema: objectSchema({ layer: { type: ['string', 'integer'] }, locked: { type: 'boolean' }, includeBounds: { type: 'boolean' }, includePins: { type: 'boolean' } }) },
|
|
@@ -463,6 +480,11 @@ const pendingCalls = new Map()
|
|
|
463
480
|
// EDA's own confirmation dialog, so "did the user confirm?" is answered by
|
|
464
481
|
// watching whether new PCB components appear (low-frequency polling).
|
|
465
482
|
let pcbComponentBaseline = null
|
|
483
|
+
// Net-label capability, probed once per session (null = unknown), plus the plain
|
|
484
|
+
// texts placed while the real net-label API was unavailable: without this registry
|
|
485
|
+
// those labels could not be listed or cleaned up.
|
|
486
|
+
let netLabelSupport = null
|
|
487
|
+
const placedTextLabels = []
|
|
466
488
|
|
|
467
489
|
function sleep(ms) {
|
|
468
490
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
@@ -507,7 +529,7 @@ function sendToClient(client, method, args, timeoutMs) {
|
|
|
507
529
|
async function callExtension(method, args, timeoutMs) {
|
|
508
530
|
const pool = pickClients()
|
|
509
531
|
if (pool.length === 0) {
|
|
510
|
-
throw new Error('JLCEDA bridge extension is not connected. Open JLCEDA Pro, import dsh-eda-bridge-extension.zip, enable "Allow interactive with external", and keep a schematic or PCB page open.')
|
|
532
|
+
throw new Error('JLCEDA bridge extension is not connected. Open JLCEDA Pro, import dsh-eda-bridge-extension-' + VERSION + '.zip, enable "Allow interactive with external", and keep a schematic or PCB page open.')
|
|
511
533
|
}
|
|
512
534
|
const deadline = Date.now() + timeoutMs
|
|
513
535
|
let lastError
|
|
@@ -960,31 +982,89 @@ async function parseNetlist(previous, timeoutMs) {
|
|
|
960
982
|
return { source: 'sch_Netlist.getNetlist', count: nets.length, nets, netlistBytes: text.length }
|
|
961
983
|
}
|
|
962
984
|
|
|
963
|
-
async function placeNetLabel(net, x, y, fontSize, timeoutMs) {
|
|
985
|
+
async function placeNetLabel(net, x, y, fontSize, timeoutMs, options = {}) {
|
|
964
986
|
if (net === '' || net === 'undefined') throw new Error('a net name is required')
|
|
965
987
|
assertNetName(String(net), 'eda_place_net_label')
|
|
966
988
|
const size = fontSize === undefined ? 12 : Number(fontSize)
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
? { hint: 'No label primitive was created: the schematic page must be the ACTIVE document (sch_PrimitiveText.create is tab-dependent). Use eda_get_active_document / eda_open_document, then retry.' }
|
|
984
|
-
: {}),
|
|
985
|
-
label,
|
|
989
|
+
if (options.probe === true) netLabelSupport = null
|
|
990
|
+
let failure = null
|
|
991
|
+
// The capability is probed ONCE per session: on JLCEDA Pro 3.2.186 createNetLabel
|
|
992
|
+
// exists but never resolves, and paying the 4s timeout for every one of hundreds
|
|
993
|
+
// of labels is what turned a 133-label run into 15 minutes.
|
|
994
|
+
if (netLabelSupport !== false) {
|
|
995
|
+
try {
|
|
996
|
+
const raw = await callExtension('sch_PrimitiveAttribute.createNetLabel', [x, y, net], Math.min(timeoutMs, 4000))
|
|
997
|
+
if (raw === undefined || raw === null) throw new Error('createNetLabel returned nothing')
|
|
998
|
+
netLabelSupport = true
|
|
999
|
+
return { method: 'netLabel', net, x, y, label: summarizePrimitive(raw) }
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1002
|
+
if (!/not found|not available|not allowed|timed out|timeout|nothing|undefined/i.test(message)) throw error
|
|
1003
|
+
failure = message
|
|
1004
|
+
netLabelSupport = false
|
|
986
1005
|
}
|
|
987
1006
|
}
|
|
1007
|
+
const raw = await callExtension('sch_PrimitiveText.create', [x, y, net, 0, null, null, size, false, false, false, 1], timeoutMs)
|
|
1008
|
+
const label = summarizePrimitive(raw)
|
|
1009
|
+
if (label.primitiveId !== undefined) {
|
|
1010
|
+
placedTextLabels.push({ net: String(net), x, y, primitiveId: label.primitiveId })
|
|
1011
|
+
if (placedTextLabels.length > 2000) placedTextLabels.shift()
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
method: 'text', net, x, y, created: label.primitiveId !== undefined,
|
|
1015
|
+
warning: 'createNetLabel is unavailable on this EDA build (' + (failure ?? 'probe already failed this session') +
|
|
1016
|
+
'), so a plain TEXT label was placed: it carries no electrical meaning. The stub wire drawn by the caller is what joins the net; eda_get_net_labels lists these texts by name and id.',
|
|
1017
|
+
...(label.primitiveId === undefined
|
|
1018
|
+
? { hint: 'No label primitive was created: the schematic page must be the ACTIVE document (sch_PrimitiveText.create is tab-dependent). Use eda_get_active_document / eda_open_document, then retry.' }
|
|
1019
|
+
: {}),
|
|
1020
|
+
label,
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Place a net flag or net label at a pin, pushing it outwards with a stub wire. */
|
|
1025
|
+
async function placeAtPin(toolName, args, timeoutMs) {
|
|
1026
|
+
const pinPrimitiveId = String(args.pinPrimitiveId ?? '')
|
|
1027
|
+
if (pinPrimitiveId === '') throw new Error('pinPrimitiveId is required')
|
|
1028
|
+
const isFlag = toolName === 'eda_place_net_flag_at_pin'
|
|
1029
|
+
assertNetName(String(args.net ?? ''), toolName)
|
|
1030
|
+
const pin = await callExtension('sch_PrimitivePin.get', [pinPrimitiveId], timeoutMs)
|
|
1031
|
+
const pinRecord = asRecord(pin)
|
|
1032
|
+
const pinX = Number(pickValue(pinRecord, 'X', 'x'))
|
|
1033
|
+
const pinY = Number(pickValue(pinRecord, 'Y', 'y'))
|
|
1034
|
+
if (!Number.isFinite(pinX) || !Number.isFinite(pinY)) throw new Error('could not read pin position for ' + pinPrimitiveId)
|
|
1035
|
+
const pinRotation = Number(pickValue(pinRecord, 'Rotation', 'rotation') ?? 0)
|
|
1036
|
+
const direction = pinDirection(pinRotation)
|
|
1037
|
+
const explicitOffset = args.offsetX !== undefined || args.offsetY !== undefined
|
|
1038
|
+
// Placing a flag/label exactly ON the pin covers the symbol. Default: push it
|
|
1039
|
+
// outwards along the pin direction and keep connectivity with a short stub wire.
|
|
1040
|
+
const stubLength = args.stubLength === undefined
|
|
1041
|
+
? (explicitOffset ? 0 : 20)
|
|
1042
|
+
: Math.max(0, Number(args.stubLength))
|
|
1043
|
+
const x = pinX + direction.dx * stubLength + Number(args.offsetX ?? 0)
|
|
1044
|
+
const y = pinY + direction.dy * stubLength + Number(args.offsetY ?? 0)
|
|
1045
|
+
if (args.allowOutsideFrame !== true) {
|
|
1046
|
+
const problem = checkPointInFrame(x, y, args.paper, 5) ?? checkPointAgainstTitleBlock(x, y, args.paper, 5)
|
|
1047
|
+
if (problem !== undefined) {
|
|
1048
|
+
throw new Error(toolName + ' refused: ' + problem + ' - reduce stubLength or pass offsetX/offsetY to place it elsewhere.')
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
let stub
|
|
1052
|
+
if (stubLength > 0 && typeof args.net === 'string' && args.net !== '') {
|
|
1053
|
+
stub = summarizePrimitive(await callExtension('sch_PrimitiveWire.create', [
|
|
1054
|
+
[pinX, pinY, x, y], String(args.net), args.color === undefined ? undefined : String(args.color),
|
|
1055
|
+
args.lineWidth === undefined ? undefined : Number(args.lineWidth), args.lineType === undefined ? undefined : Number(args.lineType),
|
|
1056
|
+
], timeoutMs))
|
|
1057
|
+
}
|
|
1058
|
+
const placement = { pin: { primitiveId: pinPrimitiveId, x: pinX, y: pinY, rotation: pinRotation }, direction, stubLength, x, y, stub }
|
|
1059
|
+
if (isFlag) {
|
|
1060
|
+
const flag = await callExtension('sch_PrimitiveComponent.createNetFlag', [
|
|
1061
|
+
String(args.identification), String(args.net), x, y,
|
|
1062
|
+
args.rotation === undefined ? pinRotation : Number(args.rotation), Boolean(args.mirror ?? false),
|
|
1063
|
+
], timeoutMs)
|
|
1064
|
+
return { ...placement, flag: summarizePrimitive(flag) }
|
|
1065
|
+
}
|
|
1066
|
+
const label = await placeNetLabel(String(args.net), x, y, args.fontSize, timeoutMs)
|
|
1067
|
+
return { ...label, ...placement }
|
|
988
1068
|
}
|
|
989
1069
|
|
|
990
1070
|
/**
|
|
@@ -1055,6 +1135,45 @@ async function resolveProjectUuid(args, timeoutMs) {
|
|
|
1055
1135
|
return { uuid: matched.uuid, matched }
|
|
1056
1136
|
}
|
|
1057
1137
|
|
|
1138
|
+
/** Wait (bounded) until getCurrentProjectInfo reports the expected project uuid. */
|
|
1139
|
+
async function waitForActiveProject(uuid, timeoutMs = 4000) {
|
|
1140
|
+
const deadline = Date.now() + timeoutMs
|
|
1141
|
+
let lastActive = null
|
|
1142
|
+
for (;;) {
|
|
1143
|
+
try {
|
|
1144
|
+
const info = asRecord(await callExtension('dmt_Project.getCurrentProjectInfo', [], 3000))
|
|
1145
|
+
lastActive = String(pickValue(info, 'uuid', 'Uuid') ?? '') || null
|
|
1146
|
+
if (lastActive === uuid) return { active: true, project: info }
|
|
1147
|
+
} catch {
|
|
1148
|
+
// The app answers undefined while the project is switching.
|
|
1149
|
+
}
|
|
1150
|
+
if (Date.now() > deadline) return { active: false, project: null, lastActive }
|
|
1151
|
+
await sleep(400)
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Collect the internal ids a project owns (schematic / page / pcb uuids). */
|
|
1156
|
+
function internalIdsOf(info) {
|
|
1157
|
+
const ids = new Set()
|
|
1158
|
+
const boards = Array.isArray(asRecord(info).data) ? asRecord(info).data : []
|
|
1159
|
+
for (const board of boards) {
|
|
1160
|
+
const record = asRecord(board)
|
|
1161
|
+
const boardUuid = String(pickValue(record, 'uuid', 'Uuid') ?? '')
|
|
1162
|
+
if (boardUuid !== '') ids.add(boardUuid)
|
|
1163
|
+
for (const docKey of ['schematic', 'pcb']) {
|
|
1164
|
+
const doc = asRecord(record[docKey])
|
|
1165
|
+
const docUuid = String(pickValue(doc, 'uuid', 'Uuid') ?? '')
|
|
1166
|
+
if (docUuid !== '') ids.add(docUuid)
|
|
1167
|
+
const pages = Array.isArray(doc.pages) ? doc.pages : []
|
|
1168
|
+
for (const page of pages) {
|
|
1169
|
+
const pageUuid = String(pickValue(asRecord(page), 'uuid', 'Uuid') ?? '')
|
|
1170
|
+
if (pageUuid !== '') ids.add(pageUuid)
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return ids
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1058
1177
|
async function executeTool(name, rawArgs, timeoutMs) {
|
|
1059
1178
|
const args = asRecord(rawArgs)
|
|
1060
1179
|
const tool = PCB_TOOL_ALIASES[name] ?? name
|
|
@@ -1075,10 +1194,10 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1075
1194
|
versionMismatch: stale.length > 0,
|
|
1076
1195
|
mcpUnits: '0.01 inch', defaultPaper: DEFAULT_PAPER,
|
|
1077
1196
|
hint: ready.length === 0
|
|
1078
|
-
? 'No JLCEDA extension is connected. Open JLCEDA Pro and click DSH MCP -> connect, or re-import dsh-eda-bridge-extension.zip.'
|
|
1197
|
+
? 'No JLCEDA extension is connected. Open JLCEDA Pro and click DSH MCP -> connect, or re-import dsh-eda-bridge-extension-' + VERSION + '.zip.'
|
|
1079
1198
|
: (stale.length > 0
|
|
1080
1199
|
? 'Connected EDA extension ' + stale.join(', ') + ' does not match bridge ' + VERSION +
|
|
1081
|
-
'. In JLCEDA Pro remove the OLD dsh-eda-bridge extension first, import dsh-eda-bridge-extension.zip again, then restart the app; a simple reload keeps the old copy.'
|
|
1200
|
+
'. In JLCEDA Pro remove the OLD dsh-eda-bridge extension first, import dsh-eda-bridge-extension-' + VERSION + '.zip again, then restart the app; a simple reload keeps the old copy.'
|
|
1082
1201
|
: 'ok'),
|
|
1083
1202
|
}
|
|
1084
1203
|
}
|
|
@@ -1136,7 +1255,35 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1136
1255
|
const landed = raw !== null && raw !== undefined
|
|
1137
1256
|
let opened = false
|
|
1138
1257
|
if (args.open === true && uuid !== undefined) {
|
|
1139
|
-
try {
|
|
1258
|
+
try {
|
|
1259
|
+
await callExtension('dmt_Project.openProject', [uuid], timeoutMs)
|
|
1260
|
+
// Same as eda_open_project: trust the observed state, not the return value.
|
|
1261
|
+
opened = (await waitForActiveProject(uuid, 4000)).active
|
|
1262
|
+
} catch {
|
|
1263
|
+
opened = false
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
// Import copies the file verbatim, so schematic/page/pcb uuids inside the new
|
|
1267
|
+
// project can collide with the source. Detect it and say so instead of leaving a
|
|
1268
|
+
// silent data-integrity trap.
|
|
1269
|
+
let collisions = null
|
|
1270
|
+
if (landed && uuid !== undefined && args.checkCollisions !== false) {
|
|
1271
|
+
try {
|
|
1272
|
+
const mine = await callExtension('dmt_Project.getProjectInfo', [uuid], toolTimeout(args, timeoutMs))
|
|
1273
|
+
const myIds = internalIdsOf(mine)
|
|
1274
|
+
const others = await callExtension('dmt_Project.getAllProjectsUuid', [], toolTimeout(args, timeoutMs))
|
|
1275
|
+
const list = (Array.isArray(others) ? others.map(String) : []).filter(other => other !== uuid).slice(0, 20)
|
|
1276
|
+
collisions = []
|
|
1277
|
+
for (const other of list) {
|
|
1278
|
+
const info = await callExtension('dmt_Project.getProjectInfo', [other], timeoutMs).catch(() => undefined)
|
|
1279
|
+
const shared = [...internalIdsOf(info)].filter(sharedId => myIds.has(sharedId))
|
|
1280
|
+
if (shared.length > 0) {
|
|
1281
|
+
collisions.push({ projectUuid: other, friendlyName: pickValue(asRecord(info), 'friendlyName', 'FriendlyName') ?? null, sharedIds: shared })
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
} catch {
|
|
1285
|
+
collisions = null
|
|
1286
|
+
}
|
|
1140
1287
|
}
|
|
1141
1288
|
return {
|
|
1142
1289
|
ok: landed,
|
|
@@ -1146,6 +1293,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1146
1293
|
teamUuid,
|
|
1147
1294
|
uuid: uuid ?? null,
|
|
1148
1295
|
opened,
|
|
1296
|
+
collisions,
|
|
1149
1297
|
result: raw ?? null,
|
|
1150
1298
|
hint: landed ? null : 'The app returned nothing: the import did not create a project. That means this build ignores the saveTo target (same limitation as createProject), so import cannot be used to create projects here.',
|
|
1151
1299
|
}
|
|
@@ -1247,9 +1395,18 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1247
1395
|
case 'eda_open_project': {
|
|
1248
1396
|
const resolvedProject = await resolveProjectUuid(args, timeoutMs)
|
|
1249
1397
|
const uuid = resolvedProject.uuid
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
|
|
1398
|
+
const returned = await callExtension('dmt_Project.openProject', [uuid], toolTimeout(args, timeoutMs))
|
|
1399
|
+
// openProject returned false on the first call although the switch had already
|
|
1400
|
+
// happened, so report what the app actually has open instead of its boolean.
|
|
1401
|
+
const activation = await waitForActiveProject(uuid, args.verifyMs === undefined ? 4000 : Number(args.verifyMs))
|
|
1402
|
+
return {
|
|
1403
|
+
opened: activation.active,
|
|
1404
|
+
openReturned: returned !== false,
|
|
1405
|
+
uuid,
|
|
1406
|
+
matched: resolvedProject.matched,
|
|
1407
|
+
current: activation.active ? summarizePrimitive(activation.project) : null,
|
|
1408
|
+
...(activation.active ? {} : { hint: 'The project did not become active within the verification window (last active: ' + (activation.lastActive ?? 'unknown') + '); it may still be loading, or the app refused the switch.' }),
|
|
1409
|
+
}
|
|
1253
1410
|
}
|
|
1254
1411
|
|
|
1255
1412
|
case 'eda_get_workspace': {
|
|
@@ -1337,13 +1494,30 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1337
1494
|
const source = await resolveProjectUuid({
|
|
1338
1495
|
uuid: args.sourceUuid, name: args.sourceName,
|
|
1339
1496
|
}, timeoutMs)
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1497
|
+
// copyProject answered nothing for every argument combination on 3.2.186, and
|
|
1498
|
+
// returning {copied:false} with no reason left the caller guessing.
|
|
1499
|
+
let copyRaw
|
|
1500
|
+
try {
|
|
1501
|
+
copyRaw = await callExtension('dmt_Project.copyProject', [
|
|
1502
|
+
source.uuid,
|
|
1503
|
+
args.targetTeamUuid === undefined ? undefined : String(args.targetTeamUuid),
|
|
1504
|
+
args.targetFolderUuid === undefined ? undefined : String(args.targetFolderUuid),
|
|
1505
|
+
args.newFriendlyName === undefined ? undefined : String(args.newFriendlyName),
|
|
1506
|
+
args.newName === undefined ? undefined : String(args.newName),
|
|
1507
|
+
], toolTimeout(args, timeoutMs))
|
|
1508
|
+
} catch (failure) {
|
|
1509
|
+
throw new Error('eda_copy_project failed: ' + (failure instanceof Error ? failure.message : String(failure)))
|
|
1510
|
+
}
|
|
1511
|
+
const copyUuid = typeof copyRaw === 'string' ? copyRaw : String(pickValue(asRecord(copyRaw), 'uuid', 'Uuid') ?? '')
|
|
1512
|
+
return {
|
|
1513
|
+
copied: copyUuid !== '',
|
|
1514
|
+
sourceUuid: source.uuid,
|
|
1515
|
+
uuid: copyUuid === '' ? null : copyUuid,
|
|
1516
|
+
raw: copyRaw ?? null,
|
|
1517
|
+
hint: copyUuid === ''
|
|
1518
|
+
? 'The app returned no uuid and created nothing: copyProject does not work on this build for local projects (same family as createProject/deleteProject). Duplicate it in the app, or export with eda_export_project and import it back with eda_import_project to get an independent copy.'
|
|
1519
|
+
: null,
|
|
1520
|
+
}
|
|
1347
1521
|
return { copied: typeof uuid === 'string' && uuid !== '', sourceUuid: source.uuid, uuid: uuid ?? null }
|
|
1348
1522
|
}
|
|
1349
1523
|
|
|
@@ -1376,7 +1550,8 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1376
1550
|
let opened = false
|
|
1377
1551
|
if (args.open === true) {
|
|
1378
1552
|
try {
|
|
1379
|
-
|
|
1553
|
+
await callExtension('dmt_Project.openProject', [uuid], timeoutMs)
|
|
1554
|
+
opened = (await waitForActiveProject(uuid, 4000)).active
|
|
1380
1555
|
} catch {
|
|
1381
1556
|
opened = false
|
|
1382
1557
|
}
|
|
@@ -1459,7 +1634,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1459
1634
|
waitedMs: waited.waitedMs,
|
|
1460
1635
|
hint: waited.connected
|
|
1461
1636
|
? 'JLCEDA is connected; the eda_* tools can be used now.'
|
|
1462
|
-
: 'The app was started but no EDA extension connected yet. In JLCEDA Pro: 扩展 → DSH MCP → 连接 (and enable “允许外部交互”); the extension must be imported once from dsh-eda-bridge-extension.zip.',
|
|
1637
|
+
: 'The app was started but no EDA extension connected yet. In JLCEDA Pro: 扩展 → DSH MCP → 连接 (and enable “允许外部交互”); the extension must be imported once from dsh-eda-bridge-extension-' + VERSION + '.zip.',
|
|
1463
1638
|
}
|
|
1464
1639
|
}
|
|
1465
1640
|
|
|
@@ -1559,70 +1734,108 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1559
1734
|
case 'eda_get_pins': {
|
|
1560
1735
|
const componentPrimitiveId = String(args.componentPrimitiveId ?? '')
|
|
1561
1736
|
if (componentPrimitiveId === '') throw new Error('componentPrimitiveId is required')
|
|
1737
|
+
// One call already returns pinName/pinNumber (ISCH_PrimitiveComponentPin);
|
|
1738
|
+
// summarising it used to drop exactly those fields, forcing one sch_PrimitivePin.get
|
|
1739
|
+
// round trip per pin (135 of them for a mid-size schematic).
|
|
1562
1740
|
const raw = await callExtension('sch_PrimitiveComponent.getAllPinsByPrimitiveId', [componentPrimitiveId], timeoutMs)
|
|
1563
|
-
if (Array.isArray(raw)) return raw
|
|
1564
|
-
return raw
|
|
1741
|
+
if (!Array.isArray(raw)) return raw
|
|
1742
|
+
return raw.map((entry) => {
|
|
1743
|
+
const record = asRecord(entry)
|
|
1744
|
+
return {
|
|
1745
|
+
primitiveId: pickValue(record, 'PrimitiveId', 'primitiveId'),
|
|
1746
|
+
pinNumber: pickValue(record, 'PinNumber', 'pinNumber') ?? null,
|
|
1747
|
+
pinName: pickValue(record, 'PinName', 'pinName') ?? null,
|
|
1748
|
+
x: pickValue(record, 'X', 'x'),
|
|
1749
|
+
y: pickValue(record, 'Y', 'y'),
|
|
1750
|
+
rotation: pickValue(record, 'Rotation', 'rotation') ?? 0,
|
|
1751
|
+
pinLength: pickValue(record, 'PinLength', 'pinLength') ?? null,
|
|
1752
|
+
noConnected: pickValue(record, 'NoConnected', 'noConnected') ?? null,
|
|
1753
|
+
}
|
|
1754
|
+
})
|
|
1565
1755
|
}
|
|
1566
1756
|
|
|
1567
1757
|
case 'eda_place_net_flag_at_pin':
|
|
1568
|
-
case 'eda_place_net_label_at_pin':
|
|
1569
|
-
|
|
1570
|
-
if (pinPrimitiveId === '') throw new Error('pinPrimitiveId is required')
|
|
1571
|
-
assertNetName(String(args.net ?? ''), name === 'eda_place_net_flag_at_pin' ? 'eda_place_net_flag_at_pin' : 'eda_place_net_label_at_pin')
|
|
1572
|
-
const pin = await callExtension('sch_PrimitivePin.get', [pinPrimitiveId], timeoutMs)
|
|
1573
|
-
const pinRecord = asRecord(pin)
|
|
1574
|
-
const pinX = Number(pickValue(pinRecord, 'X', 'x'))
|
|
1575
|
-
const pinY = Number(pickValue(pinRecord, 'Y', 'y'))
|
|
1576
|
-
if (!Number.isFinite(pinX) || !Number.isFinite(pinY)) throw new Error('could not read pin position for ' + pinPrimitiveId)
|
|
1577
|
-
const pinRotation = Number(pickValue(pinRecord, 'Rotation', 'rotation') ?? 0)
|
|
1578
|
-
const direction = pinDirection(pinRotation)
|
|
1579
|
-
const explicitOffset = args.offsetX !== undefined || args.offsetY !== undefined
|
|
1580
|
-
// Placing a flag/label exactly ON the pin covers the symbol. Default: push it
|
|
1581
|
-
// outwards along the pin direction and keep connectivity with a short stub wire.
|
|
1582
|
-
const stubLength = args.stubLength === undefined
|
|
1583
|
-
? (explicitOffset ? 0 : 20)
|
|
1584
|
-
: Math.max(0, Number(args.stubLength))
|
|
1585
|
-
const x = pinX + direction.dx * stubLength + Number(args.offsetX ?? 0)
|
|
1586
|
-
const y = pinY + direction.dy * stubLength + Number(args.offsetY ?? 0)
|
|
1587
|
-
if (args.allowOutsideFrame !== true) {
|
|
1588
|
-
const problem = checkPointInFrame(x, y, args.paper, 5) ?? checkPointAgainstTitleBlock(x, y, args.paper, 5)
|
|
1589
|
-
if (problem !== undefined) {
|
|
1590
|
-
throw new Error((name === 'eda_place_net_flag_at_pin' ? 'eda_place_net_flag_at_pin' : 'eda_place_net_label_at_pin') +
|
|
1591
|
-
' refused: ' + problem + ' - reduce stubLength or pass offsetX/offsetY to place it elsewhere.')
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
let stub
|
|
1595
|
-
if (stubLength > 0 && typeof args.net === 'string' && args.net !== '') {
|
|
1596
|
-
stub = summarizePrimitive(await callExtension('sch_PrimitiveWire.create', [
|
|
1597
|
-
[pinX, pinY, x, y], String(args.net), args.color === undefined ? undefined : String(args.color),
|
|
1598
|
-
args.lineWidth === undefined ? undefined : Number(args.lineWidth), args.lineType === undefined ? undefined : Number(args.lineType),
|
|
1599
|
-
], timeoutMs))
|
|
1600
|
-
}
|
|
1601
|
-
const placement = { pin: { primitiveId: pinPrimitiveId, x: pinX, y: pinY, rotation: pinRotation }, direction, stubLength, x, y, stub }
|
|
1602
|
-
if (name === 'eda_place_net_flag_at_pin') {
|
|
1603
|
-
const flag = await callExtension('sch_PrimitiveComponent.createNetFlag', [
|
|
1604
|
-
String(args.identification), String(args.net), x, y,
|
|
1605
|
-
args.rotation === undefined ? pinRotation : Number(args.rotation), Boolean(args.mirror ?? false),
|
|
1606
|
-
], timeoutMs)
|
|
1607
|
-
return { ...placement, flag: summarizePrimitive(flag) }
|
|
1608
|
-
}
|
|
1609
|
-
const label = await placeNetLabel(String(args.net), x, y, args.fontSize, timeoutMs)
|
|
1610
|
-
return { ...label, ...placement }
|
|
1611
|
-
}
|
|
1758
|
+
case 'eda_place_net_label_at_pin':
|
|
1759
|
+
return await placeAtPin(name, args, timeoutMs)
|
|
1612
1760
|
|
|
1613
1761
|
case 'eda_place_net_label': {
|
|
1614
1762
|
const x = Number(args.x)
|
|
1615
1763
|
const y = Number(args.y)
|
|
1616
1764
|
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('eda_place_net_label requires numeric x/y')
|
|
1617
|
-
return await placeNetLabel(String(args.net), x, y, args.fontSize, timeoutMs)
|
|
1765
|
+
return await placeNetLabel(String(args.net), x, y, args.fontSize, timeoutMs, { probe: args.probe === true })
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
case 'eda_place_net_labels': {
|
|
1769
|
+
// One MCP round trip for many labels: a 133-label schematic used to exceed the
|
|
1770
|
+
// caller's timeout with no way to learn what had already been placed.
|
|
1771
|
+
const items = Array.isArray(args.items) ? args.items.map(asRecord) : []
|
|
1772
|
+
if (items.length === 0) throw new Error('eda_place_net_labels requires items: [{ pinPrimitiveId, net }] or [{ x, y, net }]')
|
|
1773
|
+
const results = []
|
|
1774
|
+
for (const item of items) {
|
|
1775
|
+
const net = item.net === undefined ? '' : String(item.net)
|
|
1776
|
+
try {
|
|
1777
|
+
const placed = item.pinPrimitiveId !== undefined
|
|
1778
|
+
? await placeAtPin('eda_place_net_label_at_pin', { ...item, net }, timeoutMs)
|
|
1779
|
+
: await placeNetLabel(net, Number(item.x), Number(item.y), item.fontSize, timeoutMs, { probe: args.probe === true })
|
|
1780
|
+
results.push({ ok: true, net, ...placed })
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
results.push({ ok: false, net, error: error instanceof Error ? error.message : String(error) })
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
const done = results.filter(entry => entry.ok).length
|
|
1786
|
+
return {
|
|
1787
|
+
total: items.length,
|
|
1788
|
+
done,
|
|
1789
|
+
failed: items.length - done,
|
|
1790
|
+
method: netLabelSupport === true ? 'netLabel' : 'text',
|
|
1791
|
+
results,
|
|
1792
|
+
}
|
|
1618
1793
|
}
|
|
1619
1794
|
|
|
1620
1795
|
case 'eda_get_net_labels': {
|
|
1621
|
-
|
|
1796
|
+
// Enumerate every label-like primitive: net labels/flags/ports are attribute
|
|
1797
|
+
// primitives, while the text fallback (createNetLabel unavailable) lands in
|
|
1798
|
+
// sch_PrimitiveText. Listing only attributes reported count:0 right after a
|
|
1799
|
+
// successful placement, so the labels could not be found or cleaned up.
|
|
1800
|
+
const attributes = await callExtension('sch_PrimitiveAttribute.getAll', [
|
|
1622
1801
|
args.parentPrimitiveId === undefined ? undefined : String(args.parentPrimitiveId),
|
|
1623
|
-
], timeoutMs)
|
|
1624
|
-
|
|
1625
|
-
|
|
1802
|
+
], timeoutMs).catch(() => [])
|
|
1803
|
+
const texts = args.parentPrimitiveId === undefined
|
|
1804
|
+
? await callExtension('sch_PrimitiveText.getAll', [], timeoutMs).catch(() => [])
|
|
1805
|
+
: []
|
|
1806
|
+
const entries = []
|
|
1807
|
+
for (const entry of Array.isArray(attributes) ? attributes : []) {
|
|
1808
|
+
const record = asRecord(entry)
|
|
1809
|
+
entries.push({
|
|
1810
|
+
primitiveId: pickValue(record, 'PrimitiveId', 'primitiveId'),
|
|
1811
|
+
kind: String(pickValue(record, 'PrimitiveType', 'primitiveType', 'Type', 'type') ?? 'attribute'),
|
|
1812
|
+
net: pickValue(record, 'Net', 'net') ?? null,
|
|
1813
|
+
userValue: pickValue(record, 'UserValue', 'userValue', 'Value', 'value') ?? null,
|
|
1814
|
+
x: pickValue(record, 'X', 'x'),
|
|
1815
|
+
y: pickValue(record, 'Y', 'y'),
|
|
1816
|
+
})
|
|
1817
|
+
}
|
|
1818
|
+
for (const entry of Array.isArray(texts) ? texts : []) {
|
|
1819
|
+
const record = asRecord(entry)
|
|
1820
|
+
const content = String(pickValue(record, 'Content', 'content', 'Text', 'text') ?? '')
|
|
1821
|
+
if (content === '') continue
|
|
1822
|
+
entries.push({
|
|
1823
|
+
primitiveId: pickValue(record, 'PrimitiveId', 'primitiveId'),
|
|
1824
|
+
kind: 'text',
|
|
1825
|
+
net: content,
|
|
1826
|
+
userValue: content,
|
|
1827
|
+
x: pickValue(record, 'X', 'x'),
|
|
1828
|
+
y: pickValue(record, 'Y', 'y'),
|
|
1829
|
+
})
|
|
1830
|
+
}
|
|
1831
|
+
return {
|
|
1832
|
+
count: entries.length,
|
|
1833
|
+
attributeCount: Array.isArray(attributes) ? attributes.length : 0,
|
|
1834
|
+
textCount: Array.isArray(texts) ? texts.length : 0,
|
|
1835
|
+
sessionTextLabels: placedTextLabels.length,
|
|
1836
|
+
note: 'kind "text" entries are plain texts (no electrical meaning). Delete any entry with eda_delete_primitive(kind: "netlabel"|"netport"|"netflag"|"text", primitiveId).',
|
|
1837
|
+
labels: entries,
|
|
1838
|
+
}
|
|
1626
1839
|
}
|
|
1627
1840
|
|
|
1628
1841
|
case 'eda_get_active_document': {
|
|
@@ -1719,8 +1932,30 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1719
1932
|
case 'eda_save_document':
|
|
1720
1933
|
return await callExtension('sch_Document.save', [], timeoutMs)
|
|
1721
1934
|
|
|
1722
|
-
case 'eda_zoom_to_fit':
|
|
1723
|
-
|
|
1935
|
+
case 'eda_zoom_to_fit': {
|
|
1936
|
+
// The API reported bounds outside the sheet (left:-340, bottom:-772.9 on A4),
|
|
1937
|
+
// which is useless for a caller that wants to know what it is looking at.
|
|
1938
|
+
// Clamp the region to the page frame and re-apply it.
|
|
1939
|
+
const raw = await callExtension('dmt_EditorControl.zoomToAllPrimitives', [args.tabId], timeoutMs)
|
|
1940
|
+
const record = asRecord(raw)
|
|
1941
|
+
const left = Number(pickValue(record, 'left', 'Left'))
|
|
1942
|
+
const right = Number(pickValue(record, 'right', 'Right'))
|
|
1943
|
+
const bottom = Number(pickValue(record, 'bottom', 'Bottom'))
|
|
1944
|
+
const top = Number(pickValue(record, 'top', 'Top'))
|
|
1945
|
+
if (![left, right, bottom, top].every(value => Number.isFinite(value))) return raw
|
|
1946
|
+
const frame = paperOf(args.paper).frame
|
|
1947
|
+
const clamped = {
|
|
1948
|
+
left: Math.max(frame.left, left), right: Math.min(frame.right, right),
|
|
1949
|
+
bottom: Math.max(frame.bottom, bottom), top: Math.min(frame.top, top),
|
|
1950
|
+
}
|
|
1951
|
+
if (clamped.left >= clamped.right || clamped.bottom >= clamped.top) {
|
|
1952
|
+
return { ...record, requested: { left, right, bottom, top }, clamped: null, note: 'the primitive bounds do not overlap the page frame' }
|
|
1953
|
+
}
|
|
1954
|
+
await callExtension('dmt_EditorControl.zoomToRegion', [
|
|
1955
|
+
clamped.left, clamped.right, clamped.top, clamped.bottom, args.tabId,
|
|
1956
|
+
], timeoutMs)
|
|
1957
|
+
return { ...record, requested: { left, right, bottom, top }, clamped }
|
|
1958
|
+
}
|
|
1724
1959
|
|
|
1725
1960
|
case 'eda_zoom_to_region':
|
|
1726
1961
|
return await callExtension('dmt_EditorControl.zoomToRegion', [
|
|
@@ -1763,23 +1998,108 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1763
1998
|
return parsed
|
|
1764
1999
|
}
|
|
1765
2000
|
|
|
1766
|
-
case 'eda_run_drc':
|
|
1767
|
-
|
|
2001
|
+
case 'eda_run_drc': {
|
|
2002
|
+
// includeVerboseError:true makes the API return one record per problem; the
|
|
2003
|
+
// boolean overload only said "something is wrong" and gave nothing to locate.
|
|
2004
|
+
const verbose = await callExtension('sch_Drc.check', [
|
|
2005
|
+
args.strict === undefined ? true : Boolean(args.strict), false, true,
|
|
2006
|
+
], toolTimeout(args, timeoutMs))
|
|
2007
|
+
if (Array.isArray(verbose)) {
|
|
2008
|
+
return {
|
|
2009
|
+
ok: verbose.length === 0,
|
|
2010
|
+
count: verbose.length,
|
|
2011
|
+
entries: verbose.map((entry) => {
|
|
2012
|
+
const record = asRecord(entry)
|
|
2013
|
+
return {
|
|
2014
|
+
type: pickValue(record, 'Type', 'type', 'Level', 'level') ?? null,
|
|
2015
|
+
message: pickValue(record, 'Message', 'message', 'Error', 'error', 'Description', 'description') ?? null,
|
|
2016
|
+
primitiveId: pickValue(record, 'PrimitiveId', 'primitiveId') ?? null,
|
|
2017
|
+
x: pickValue(record, 'X', 'x') ?? null,
|
|
2018
|
+
y: pickValue(record, 'Y', 'y') ?? null,
|
|
2019
|
+
raw: entry,
|
|
2020
|
+
}
|
|
2021
|
+
}),
|
|
2022
|
+
hint: 'Field names vary by EDA build; "raw" keeps the untouched record.',
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
return { ok: Boolean(verbose), count: null, entries: [], raw: verbose }
|
|
2026
|
+
}
|
|
1768
2027
|
|
|
1769
2028
|
case 'eda_auto_layout':
|
|
1770
|
-
|
|
2029
|
+
case 'eda_auto_route': {
|
|
2030
|
+
// Standing rule from the user: do not auto-lay-out or auto-route unless asked.
|
|
2031
|
+
if (ALLOW_AUTO_LAYOUT !== true && args.force !== true) {
|
|
2032
|
+
throw new Error(name + ' is disabled by policy (settings allowAutoLayoutRouting=false, env DSH_EDA_ALLOW_AUTO_LAYOUT). Do NOT retry with force:true unless the user explicitly asked for automatic layout/routing.',
|
|
2033
|
+
'',
|
|
2034
|
+
'Do this instead, in order:',
|
|
2035
|
+
'1. eda_get_active_document — make sure a schematic page is active (eda_open_document if not).',
|
|
2036
|
+
'2. eda_get_page_info — read the A4 frame and the safe areas before placing anything.',
|
|
2037
|
+
'3. eda_get_components — see what is already placed and where (x/y + bounding boxes) to avoid overlap.',
|
|
2038
|
+
'4. Place or move parts yourself on a 10-unit grid: eda_place_component for new ones, eda_move_component (keeps designator/value) to re-grid existing ones. Signal flow left to right, power rails on top, ground at the bottom, >=20 units between symbols for wires and labels.',
|
|
2039
|
+
'5. Connect with eda_draw_wire for local nets; for anything that spans the sheet use eda_place_net_port or a net label (a wire net name is overridden by connectivity once an endpoint touches a pin).',
|
|
2040
|
+
'6. Verify: eda_export_netlist (every net should have >=2 endpoints) and eda_run_drc (per-problem entries). For a visual check export the canvas to an image and look at it, rather than letting EDA rearrange the sheet.',
|
|
2041
|
+
'7. Call eda_workflow_guide for the full standard.',)
|
|
2042
|
+
}
|
|
2043
|
+
const payload = Array.isArray(args.uuids) ? { uuids: args.uuids.map(String) } : undefined
|
|
2044
|
+
return await callExtension(name === 'eda_auto_layout' ? 'sch_Document.autoLayout' : 'sch_Document.autoRouting', [payload], toolTimeout(args, timeoutMs))
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
case 'eda_export_netlist': {
|
|
2048
|
+
const payload = await callExtension('sch_ManufactureData.getNetlistFile', [
|
|
2049
|
+
undefined, typeof args.type === 'string' && args.type !== '' ? args.type : undefined,
|
|
2050
|
+
], toolTimeout(args, timeoutMs))
|
|
2051
|
+
const text = typeof payload === 'string'
|
|
2052
|
+
? payload
|
|
2053
|
+
: (payload !== null && typeof payload === 'object' && typeof payload.text === 'string' ? payload.text : undefined)
|
|
2054
|
+
if (text === undefined) return { parsed: false, netlist: payload ?? null }
|
|
2055
|
+
if (args.format === 'text') return { parsed: false, netlistText: text, bytes: text.length }
|
|
2056
|
+
// The API hands back a JSON document; returning it as an escaped string forced
|
|
2057
|
+
// callers to parse twice (and once more through the MCP envelope).
|
|
2058
|
+
try {
|
|
2059
|
+
return { parsed: true, bytes: text.length, netlist: JSON.parse(text) }
|
|
2060
|
+
} catch {
|
|
2061
|
+
return { parsed: false, bytes: text.length, netlistText: text }
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
case 'eda_export_bom': {
|
|
2066
|
+
const format = args.format === 'xlsx' ? 'xlsx' : 'csv'
|
|
2067
|
+
const payload = await callExtension('sch_ManufactureData.getBomFile', [undefined, format], toolTimeout(args, timeoutMs))
|
|
2068
|
+
if (format === 'xlsx') return payload
|
|
2069
|
+
const raw = typeof payload === 'string'
|
|
2070
|
+
? payload
|
|
2071
|
+
: (payload !== null && typeof payload === 'object' && typeof payload.text === 'string' ? payload.text : '')
|
|
2072
|
+
// CSV arrives as UTF-16LE bytes; returned verbatim it read as NUL-separated junk.
|
|
2073
|
+
return { format, bom: decodeTextPayload(raw) }
|
|
2074
|
+
}
|
|
1771
2075
|
|
|
1772
|
-
case '
|
|
1773
|
-
|
|
2076
|
+
case 'eda_delete_primitive': {
|
|
2077
|
+
const kind = String(args.kind)
|
|
2078
|
+
// Net ports / flags / labels are attribute primitives; without these kinds the
|
|
2079
|
+
// only way to remove one was to remember the id from the placement call.
|
|
2080
|
+
const className = kind === 'netport' || kind === 'netflag' || kind === 'netlabel'
|
|
2081
|
+
? 'sch_PrimitiveAttribute'
|
|
2082
|
+
: primitiveKindToClass(kind)
|
|
2083
|
+
return await callExtension(className + '.delete', [String(args.primitiveId)], timeoutMs)
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
case 'eda_move_component':
|
|
2087
|
+
// Schematic parts could only be moved through raw sch_PrimitiveComponent.modify.
|
|
2088
|
+
return await callExtension('sch_PrimitiveComponent.modify', [String(args.primitiveId), {
|
|
2089
|
+
x: args.x === undefined ? undefined : Number(args.x),
|
|
2090
|
+
y: args.y === undefined ? undefined : Number(args.y),
|
|
2091
|
+
rotation: args.rotation === undefined ? undefined : Number(args.rotation),
|
|
2092
|
+
mirror: args.mirror === undefined ? undefined : Boolean(args.mirror),
|
|
2093
|
+
}], timeoutMs)
|
|
1774
2094
|
|
|
1775
|
-
case '
|
|
1776
|
-
return
|
|
2095
|
+
case 'eda_list_api_methods':
|
|
2096
|
+
return { count: ALLOWED_API.size, methods: [...ALLOWED_API].sort() }
|
|
1777
2097
|
|
|
1778
|
-
case '
|
|
1779
|
-
return
|
|
2098
|
+
case 'eda_workflow_guide':
|
|
2099
|
+
return { summary: WORKFLOW_SUMMARY, guide: WORKFLOW_GUIDE, autoLayoutAllowed: ALLOW_AUTO_LAYOUT }
|
|
1780
2100
|
|
|
1781
|
-
case '
|
|
1782
|
-
return
|
|
2101
|
+
case 'eda_workflow_guide':
|
|
2102
|
+
return { summary: WORKFLOW_SUMMARY, guide: WORKFLOW_GUIDE, autoLayoutAllowed: ALLOW_AUTO_LAYOUT }
|
|
1783
2103
|
|
|
1784
2104
|
case 'eda_create_board':
|
|
1785
2105
|
return await callExtension('dmt_Board.createBoard', [args.schematicUuid, args.pcbUuid], timeoutMs)
|
|
@@ -1787,7 +2107,11 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1787
2107
|
case 'eda_api_call': {
|
|
1788
2108
|
if (!ALLOW_RAW_API) throw new Error('eda_api_call is disabled by settings (allowRawApi=false)')
|
|
1789
2109
|
const method = String(args.method ?? '')
|
|
1790
|
-
if (!ALLOWED_API.has(method))
|
|
2110
|
+
if (!ALLOWED_API.has(method)) {
|
|
2111
|
+
throw new Error('API method not allowed: ' + method + '. The extension only accepts its allow-list (' +
|
|
2112
|
+
ALLOWED_API.size + ' methods): call eda_list_api_methods to get it. Common schematic helpers such as ' +
|
|
2113
|
+
'sch_PrimitiveComponent.getAllPinsByPrimitiveId and sch_PrimitivePin.get are already wrapped by eda_get_pins.')
|
|
2114
|
+
}
|
|
1791
2115
|
return await callExtension(method, Array.isArray(args.args) ? args.args : [], timeoutMs)
|
|
1792
2116
|
}
|
|
1793
2117
|
|
|
@@ -1982,7 +2306,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
1982
2306
|
triggered: true, backgroundUnavailable: true, importResult: result,
|
|
1983
2307
|
componentsBefore: pcbComponentBaseline.length, componentsAfter: after.length,
|
|
1984
2308
|
confirmedYet: after.length > pcbComponentBaseline.length,
|
|
1985
|
-
hint: 'The imported EDA extension has no background-job helper, so the sync was awaited. Re-import dsh-eda-bridge-extension.zip to use the async + polling flow.',
|
|
2309
|
+
hint: 'The imported EDA extension has no background-job helper, so the sync was awaited. Re-import dsh-eda-bridge-extension-' + VERSION + '.zip to use the async + polling flow.',
|
|
1986
2310
|
}
|
|
1987
2311
|
}
|
|
1988
2312
|
return {
|
|
@@ -2284,7 +2608,7 @@ async function executeTool(name, rawArgs, timeoutMs) {
|
|
|
2284
2608
|
// Older EDA extension without the background-job helper: fall back to the
|
|
2285
2609
|
// blocking call (which may hit the tool timeout for large boards).
|
|
2286
2610
|
const blocking = asRecord(await callExtension('pcb_Document.autoRouting', [props], toolTimeout(args, timeoutMs)))
|
|
2287
|
-
return { ...blocking, backgroundUnavailable: true, props, hint: 'The imported EDA extension has no background-job helper, so routing was awaited. Re-import dsh-eda-bridge-extension.zip to get the async + polling flow.' }
|
|
2611
|
+
return { ...blocking, backgroundUnavailable: true, props, hint: 'The imported EDA extension has no background-job helper, so routing was awaited. Re-import dsh-eda-bridge-extension-' + VERSION + '.zip to get the async + polling flow.' }
|
|
2288
2612
|
}
|
|
2289
2613
|
const running = started.started === true || started.alreadyRunning === true
|
|
2290
2614
|
return {
|