siglent-sdg-mcp 1.0.0

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.
@@ -0,0 +1,252 @@
1
+ import * as net from "node:net";
2
+ const DEFAULT_PORT = 5025;
3
+ const DEFAULT_TIMEOUT = 5000;
4
+ const BINARY_TIMEOUT = 30000;
5
+ export class SiglentConnection {
6
+ socket = null;
7
+ host = "";
8
+ port = DEFAULT_PORT;
9
+ dataBuffer = Buffer.alloc(0);
10
+ responseResolve = null;
11
+ responseReject = null;
12
+ responseTimer = null;
13
+ expectedBinaryLength = null;
14
+ binaryDataStart = 0;
15
+ headerParsed = false;
16
+ queryQueue = [];
17
+ queryRunning = false;
18
+ async connect(host, port = DEFAULT_PORT) {
19
+ if (this.socket) {
20
+ this.disconnect();
21
+ }
22
+ this.host = host;
23
+ this.port = port;
24
+ return new Promise((resolve, reject) => {
25
+ const socket = new net.Socket();
26
+ const connectTimeout = setTimeout(() => {
27
+ socket.destroy();
28
+ reject(new Error(`Connection timeout to ${host}:${port}`));
29
+ }, 5000);
30
+ socket.on("connect", async () => {
31
+ clearTimeout(connectTimeout);
32
+ socket.removeAllListeners("error");
33
+ this.socket = socket;
34
+ this.setupSocketListeners();
35
+ try {
36
+ // Drain any welcome banner
37
+ await this.delay(200);
38
+ this.dataBuffer = Buffer.alloc(0);
39
+ // Try to set CHDR OFF for clean responses (not all SDG models support it)
40
+ try {
41
+ await this.sendCommand("CHDR OFF");
42
+ await this.delay(100);
43
+ }
44
+ catch {
45
+ // Some models don't support CHDR — that's OK
46
+ }
47
+ // Query identification
48
+ const idn = await this.query("*IDN?");
49
+ resolve(idn);
50
+ }
51
+ catch (err) {
52
+ this.disconnect();
53
+ reject(new Error(`Connected but failed to initialize: ${err instanceof Error ? err.message : String(err)}`));
54
+ }
55
+ });
56
+ socket.on("error", (err) => {
57
+ clearTimeout(connectTimeout);
58
+ reject(new Error(`Connection failed: ${err.message}`));
59
+ });
60
+ socket.connect(port, host);
61
+ });
62
+ }
63
+ disconnect() {
64
+ const pending = this.queryQueue.splice(0);
65
+ this.queryRunning = false;
66
+ if (this.responseReject) {
67
+ this.responseReject(new Error("Connection closed"));
68
+ }
69
+ this.clearPending();
70
+ for (const q of pending) {
71
+ q.reject(new Error("Connection closed"));
72
+ }
73
+ if (this.socket) {
74
+ this.socket.removeAllListeners();
75
+ this.socket.destroy();
76
+ this.socket = null;
77
+ }
78
+ this.dataBuffer = Buffer.alloc(0);
79
+ }
80
+ isConnected() {
81
+ return this.socket !== null && !this.socket.destroyed;
82
+ }
83
+ getConnectionInfo() {
84
+ if (!this.isConnected())
85
+ return "Not connected";
86
+ return `${this.host}:${this.port}`;
87
+ }
88
+ async sendCommand(cmd) {
89
+ this.ensureConnected();
90
+ return new Promise((resolve, reject) => {
91
+ this.socket.write(cmd + "\n", (err) => {
92
+ if (err)
93
+ reject(new Error(`Write failed: ${err.message}`));
94
+ else
95
+ resolve();
96
+ });
97
+ });
98
+ }
99
+ async query(cmd, timeout = DEFAULT_TIMEOUT) {
100
+ this.ensureConnected();
101
+ const buf = await this.enqueueQuery(cmd, false, timeout);
102
+ return buf.toString("utf-8").trim();
103
+ }
104
+ async queryBinary(cmd, timeout = BINARY_TIMEOUT) {
105
+ this.ensureConnected();
106
+ return this.enqueueQuery(cmd, true, timeout);
107
+ }
108
+ enqueueQuery(cmd, binary, timeout) {
109
+ return new Promise((resolve, reject) => {
110
+ this.queryQueue.push({ cmd, binary, timeout, resolve, reject });
111
+ this.drainQueue();
112
+ });
113
+ }
114
+ drainQueue() {
115
+ if (this.queryRunning || this.queryQueue.length === 0)
116
+ return;
117
+ this.queryRunning = true;
118
+ const { cmd, binary, timeout, resolve, reject } = this.queryQueue.shift();
119
+ this.expectedBinaryLength = null;
120
+ this.binaryDataStart = 0;
121
+ this.headerParsed = false;
122
+ this.dataBuffer = Buffer.alloc(0);
123
+ this.responseResolve = (buf) => {
124
+ this.queryRunning = false;
125
+ resolve(buf);
126
+ this.drainQueue();
127
+ };
128
+ this.responseReject = (err) => {
129
+ this.queryRunning = false;
130
+ reject(err);
131
+ this.drainQueue();
132
+ };
133
+ this.responseTimer = setTimeout(() => {
134
+ this.clearPending();
135
+ const err = new Error(`${binary ? "Binary query" : "Query"} timeout after ${timeout}ms for command: ${cmd}`);
136
+ this.queryRunning = false;
137
+ reject(err);
138
+ this.drainQueue();
139
+ }, timeout);
140
+ this.socket.write(cmd + "\n", (err) => {
141
+ if (err) {
142
+ this.clearPending();
143
+ this.queryRunning = false;
144
+ reject(new Error(`Write failed: ${err.message}`));
145
+ this.drainQueue();
146
+ }
147
+ });
148
+ }
149
+ setupSocketListeners() {
150
+ this.socket.on("data", (chunk) => {
151
+ this.dataBuffer = Buffer.concat([this.dataBuffer, chunk]);
152
+ this.tryResolve();
153
+ });
154
+ this.socket.on("close", () => {
155
+ const pending = this.queryQueue.splice(0);
156
+ this.queryRunning = false;
157
+ if (this.responseReject) {
158
+ this.responseReject(new Error("Connection closed unexpectedly"));
159
+ }
160
+ this.clearPending();
161
+ for (const q of pending) {
162
+ q.reject(new Error("Connection closed unexpectedly"));
163
+ }
164
+ this.socket = null;
165
+ });
166
+ this.socket.on("error", (err) => {
167
+ if (this.responseReject) {
168
+ this.responseReject(new Error(`Socket error: ${err.message}`));
169
+ }
170
+ this.clearPending();
171
+ });
172
+ }
173
+ tryResolve() {
174
+ if (!this.responseResolve)
175
+ return;
176
+ // Try to detect binary framing format
177
+ if (!this.headerParsed && this.dataBuffer.length >= 6) {
178
+ // Check for raw BMP: starts with "BM" magic bytes
179
+ if (this.dataBuffer[0] === 0x42 && this.dataBuffer[1] === 0x4d) {
180
+ const bmpSize = this.dataBuffer.readUInt32LE(2);
181
+ if (bmpSize > 0 && bmpSize < 100_000_000) {
182
+ this.expectedBinaryLength = bmpSize;
183
+ this.binaryDataStart = 0;
184
+ this.headerParsed = true;
185
+ }
186
+ }
187
+ // Check for IEEE 488.2 definite length block: #<digitCount><digits><data>
188
+ if (!this.headerParsed) {
189
+ const hashIndex = this.dataBuffer.indexOf(0x23); // '#'
190
+ if (hashIndex >= 0 && hashIndex + 2 <= this.dataBuffer.length) {
191
+ const digitCount = this.dataBuffer[hashIndex + 1] - 0x30;
192
+ if (digitCount >= 1 && digitCount <= 9) {
193
+ const headerSize = 2 + digitCount;
194
+ if (hashIndex + headerSize <= this.dataBuffer.length) {
195
+ const lengthStr = this.dataBuffer
196
+ .subarray(hashIndex + 2, hashIndex + headerSize)
197
+ .toString("ascii");
198
+ const dataLength = parseInt(lengthStr, 10);
199
+ if (!isNaN(dataLength) && dataLength > 0) {
200
+ this.expectedBinaryLength = dataLength;
201
+ this.binaryDataStart = hashIndex + headerSize;
202
+ this.headerParsed = true;
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ if (this.expectedBinaryLength !== null && this.headerParsed) {
210
+ const trailingBytes = this.binaryDataStart === 0 ? 1 : 2;
211
+ const totalExpected = this.binaryDataStart + this.expectedBinaryLength + trailingBytes;
212
+ if (this.dataBuffer.length >= totalExpected) {
213
+ const binaryData = this.dataBuffer.subarray(this.binaryDataStart, this.binaryDataStart + this.expectedBinaryLength);
214
+ const resolve = this.responseResolve;
215
+ this.clearPending();
216
+ resolve(Buffer.from(binaryData));
217
+ }
218
+ }
219
+ else {
220
+ // Text mode: look for newline terminator
221
+ const newlineIndex = this.dataBuffer.indexOf(0x0a);
222
+ if (newlineIndex >= 0) {
223
+ const response = this.dataBuffer.subarray(0, newlineIndex);
224
+ const resolve = this.responseResolve;
225
+ this.clearPending();
226
+ resolve(Buffer.from(response));
227
+ }
228
+ }
229
+ }
230
+ clearPending() {
231
+ if (this.responseTimer) {
232
+ clearTimeout(this.responseTimer);
233
+ this.responseTimer = null;
234
+ }
235
+ this.responseResolve = null;
236
+ this.responseReject = null;
237
+ this.expectedBinaryLength = null;
238
+ this.binaryDataStart = 0;
239
+ this.headerParsed = false;
240
+ }
241
+ ensureConnected() {
242
+ if (!this.isConnected()) {
243
+ throw new Error("Not connected to waveform generator. Use the 'connect' tool first.");
244
+ }
245
+ }
246
+ delay(ms) {
247
+ return new Promise((resolve) => setTimeout(resolve, ms));
248
+ }
249
+ }
250
+ // Singleton instance
251
+ export const connection = new SiglentConnection();
252
+ //# sourceMappingURL=connection.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/build/index.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { registerConnectionTools } from "./tools/connection.js";
5
+ import { registerOutputTools } from "./tools/output.js";
6
+ import { registerBasicWaveTools } from "./tools/basic-wave.js";
7
+ import { registerModulationTools } from "./tools/modulation.js";
8
+ import { registerSweepTools } from "./tools/sweep.js";
9
+ import { registerBurstTools } from "./tools/burst.js";
10
+ import { registerArbitraryTools } from "./tools/arbitrary.js";
11
+ import { registerUtilityTools } from "./tools/utility.js";
12
+ import { registerScpiTools } from "./tools/scpi.js";
13
+ import { connection } from "./connection.js";
14
+ // Prevent uncaught errors from killing the MCP server process
15
+ process.on("uncaughtException", (err) => {
16
+ console.error("Uncaught exception:", err);
17
+ });
18
+ process.on("unhandledRejection", (reason) => {
19
+ console.error("Unhandled rejection:", reason);
20
+ });
21
+ const server = new McpServer({
22
+ name: "siglent-sdg-mcp",
23
+ version: "1.0.0",
24
+ });
25
+ // Register all tool groups
26
+ registerConnectionTools(server);
27
+ registerOutputTools(server);
28
+ registerBasicWaveTools(server);
29
+ registerModulationTools(server);
30
+ registerSweepTools(server);
31
+ registerBurstTools(server);
32
+ registerArbitraryTools(server);
33
+ registerUtilityTools(server);
34
+ registerScpiTools(server);
35
+ async function main() {
36
+ const transport = new StdioServerTransport();
37
+ await server.connect(transport);
38
+ console.error("siglent-sdg-mcp server running on stdio");
39
+ // Auto-connect if env var is set (fire-and-forget, don't block MCP startup)
40
+ const autoIp = process.env.SIGLENT_SDG_IP || process.env.SIGLENT_IP;
41
+ if (autoIp) {
42
+ const port = parseInt(process.env.SIGLENT_SDG_PORT || process.env.SIGLENT_PORT || "5025", 10);
43
+ connection.connect(autoIp, port).then((idn) => console.error(`Auto-connected to ${autoIp}: ${idn}`), (err) => {
44
+ console.error(`Auto-connect to ${autoIp} failed: ${err instanceof Error ? err.message : String(err)}`);
45
+ console.error("Use the 'connect' tool to connect manually.");
46
+ });
47
+ }
48
+ }
49
+ main().catch((error) => {
50
+ console.error("Startup error:", error);
51
+ });
52
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerArbitraryTools(server: McpServer): void;
@@ -0,0 +1,83 @@
1
+ import { z } from "zod";
2
+ import { connection } from "../connection.js";
3
+ export function registerArbitraryTools(server) {
4
+ server.tool("get_arbitrary_wave", "Get the current arbitrary waveform selection for a channel. Returns the waveform index and name.", {
5
+ channel: z.enum(["C1", "C2"]).describe("Channel to query"),
6
+ }, { readOnlyHint: true }, async ({ channel }) => {
7
+ try {
8
+ const response = await connection.query(`${channel}:ARWV?`);
9
+ return {
10
+ content: [
11
+ {
12
+ type: "text",
13
+ text: response,
14
+ },
15
+ ],
16
+ };
17
+ }
18
+ catch (err) {
19
+ return {
20
+ content: [
21
+ {
22
+ type: "text",
23
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
24
+ },
25
+ ],
26
+ isError: true,
27
+ };
28
+ }
29
+ });
30
+ server.tool("set_arbitrary_wave", "Set the arbitrary waveform for a channel by index number or by name. Use index for built-in waveforms (e.g. 2=StairUp, 18=Sinc, 26=Cardiac) or name for user-defined waveforms.", {
31
+ channel: z.enum(["C1", "C2"]).describe("Channel to configure"),
32
+ index: z
33
+ .number()
34
+ .optional()
35
+ .describe("Index of the built-in arbitrary waveform (0-198). Examples: 0=Sine, 2=StairUp, 10=ExpFal, 18=Sinc, 19=Gaussian, 26=Cardiac"),
36
+ name: z
37
+ .string()
38
+ .optional()
39
+ .describe('Name of the waveform (for user-defined waves, use quotes). Can also be a path like "Local/wave1.bin"'),
40
+ }, { readOnlyHint: false }, async ({ channel, index, name }) => {
41
+ try {
42
+ if (index === undefined && !name) {
43
+ return {
44
+ content: [
45
+ {
46
+ type: "text",
47
+ text: "Error: Either 'index' or 'name' must be specified.",
48
+ },
49
+ ],
50
+ isError: true,
51
+ };
52
+ }
53
+ let cmd;
54
+ if (index !== undefined) {
55
+ cmd = `${channel}:ARWV INDEX,${index}`;
56
+ }
57
+ else {
58
+ cmd = `${channel}:ARWV NAME,"${name}"`;
59
+ }
60
+ await connection.sendCommand(cmd);
61
+ return {
62
+ content: [
63
+ {
64
+ type: "text",
65
+ text: `Arbitrary wave set: ${cmd}`,
66
+ },
67
+ ],
68
+ };
69
+ }
70
+ catch (err) {
71
+ return {
72
+ content: [
73
+ {
74
+ type: "text",
75
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
76
+ },
77
+ ],
78
+ isError: true,
79
+ };
80
+ }
81
+ });
82
+ }
83
+ //# sourceMappingURL=arbitrary.js.map
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerBasicWaveTools(server: McpServer): void;
@@ -0,0 +1,172 @@
1
+ import { z } from "zod";
2
+ import { connection } from "../connection.js";
3
+ export function registerBasicWaveTools(server) {
4
+ server.tool("get_basic_wave", "Get the basic waveform parameters of a channel, including waveform type, frequency, amplitude, offset, phase, duty cycle, etc.", {
5
+ channel: z.enum(["C1", "C2"]).describe("Channel to query"),
6
+ }, { readOnlyHint: true }, async ({ channel }) => {
7
+ try {
8
+ const response = await connection.query(`${channel}:BSWV?`);
9
+ return {
10
+ content: [
11
+ {
12
+ type: "text",
13
+ text: response,
14
+ },
15
+ ],
16
+ };
17
+ }
18
+ catch (err) {
19
+ return {
20
+ content: [
21
+ {
22
+ type: "text",
23
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
24
+ },
25
+ ],
26
+ isError: true,
27
+ };
28
+ }
29
+ });
30
+ server.tool("configure_basic_wave", "Configure the basic waveform parameters of a channel. Multiple parameters can be set in a single call. The command builds a single BSWV command with all specified parameters.", {
31
+ channel: z.enum(["C1", "C2"]).describe("Channel to configure"),
32
+ waveform_type: z
33
+ .enum(["SINE", "SQUARE", "RAMP", "PULSE", "NOISE", "ARB", "DC", "PRBS"])
34
+ .optional()
35
+ .describe("Waveform type"),
36
+ frequency: z
37
+ .number()
38
+ .optional()
39
+ .describe("Frequency in Hz (not valid for NOISE or DC)"),
40
+ period: z
41
+ .number()
42
+ .optional()
43
+ .describe("Period in seconds (alternative to frequency)"),
44
+ amplitude: z
45
+ .number()
46
+ .optional()
47
+ .describe("Amplitude in Vpp (not valid for NOISE or DC)"),
48
+ amplitude_vrms: z
49
+ .number()
50
+ .optional()
51
+ .describe("Amplitude in Vrms"),
52
+ offset: z
53
+ .number()
54
+ .optional()
55
+ .describe("DC offset in volts (not valid for NOISE)"),
56
+ phase: z
57
+ .number()
58
+ .optional()
59
+ .describe("Phase in degrees (0-360, not valid for NOISE, PULSE, or DC)"),
60
+ duty: z
61
+ .number()
62
+ .optional()
63
+ .describe("Duty cycle in % (only for SQUARE or PULSE)"),
64
+ symmetry: z
65
+ .number()
66
+ .optional()
67
+ .describe("Symmetry in % (0-100, only for RAMP)"),
68
+ width: z
69
+ .number()
70
+ .optional()
71
+ .describe("Pulse width in seconds (only for PULSE)"),
72
+ rise: z
73
+ .number()
74
+ .optional()
75
+ .describe("Rise time in seconds (only for PULSE)"),
76
+ fall: z
77
+ .number()
78
+ .optional()
79
+ .describe("Fall time in seconds (only for PULSE)"),
80
+ delay: z
81
+ .number()
82
+ .optional()
83
+ .describe("Waveform delay in seconds"),
84
+ stdev: z
85
+ .number()
86
+ .optional()
87
+ .describe("Standard deviation in volts (only for NOISE)"),
88
+ mean: z
89
+ .number()
90
+ .optional()
91
+ .describe("Mean in volts (only for NOISE)"),
92
+ high_level: z
93
+ .number()
94
+ .optional()
95
+ .describe("High level in volts"),
96
+ low_level: z
97
+ .number()
98
+ .optional()
99
+ .describe("Low level in volts"),
100
+ }, { readOnlyHint: false }, async ({ channel, waveform_type, frequency, period, amplitude, amplitude_vrms, offset, phase, duty, symmetry, width, rise, fall, delay, stdev, mean, high_level, low_level, }) => {
101
+ try {
102
+ const parts = [];
103
+ if (waveform_type)
104
+ parts.push(`WVTP,${waveform_type}`);
105
+ if (frequency !== undefined)
106
+ parts.push(`FRQ,${frequency}`);
107
+ if (period !== undefined)
108
+ parts.push(`PERI,${period}`);
109
+ if (amplitude !== undefined)
110
+ parts.push(`AMP,${amplitude}`);
111
+ if (amplitude_vrms !== undefined)
112
+ parts.push(`AMPVRMS,${amplitude_vrms}`);
113
+ if (offset !== undefined)
114
+ parts.push(`OFST,${offset}`);
115
+ if (phase !== undefined)
116
+ parts.push(`PHSE,${phase}`);
117
+ if (duty !== undefined)
118
+ parts.push(`DUTY,${duty}`);
119
+ if (symmetry !== undefined)
120
+ parts.push(`SYM,${symmetry}`);
121
+ if (width !== undefined)
122
+ parts.push(`WIDTH,${width}`);
123
+ if (rise !== undefined)
124
+ parts.push(`RISE,${rise}`);
125
+ if (fall !== undefined)
126
+ parts.push(`FALL,${fall}`);
127
+ if (delay !== undefined)
128
+ parts.push(`DLY,${delay}`);
129
+ if (stdev !== undefined)
130
+ parts.push(`STDEV,${stdev}`);
131
+ if (mean !== undefined)
132
+ parts.push(`MEAN,${mean}`);
133
+ if (high_level !== undefined)
134
+ parts.push(`HLEV,${high_level}`);
135
+ if (low_level !== undefined)
136
+ parts.push(`LLEV,${low_level}`);
137
+ if (parts.length === 0) {
138
+ return {
139
+ content: [
140
+ {
141
+ type: "text",
142
+ text: "Error: At least one parameter must be specified.",
143
+ },
144
+ ],
145
+ isError: true,
146
+ };
147
+ }
148
+ const cmd = `${channel}:BSWV ${parts.join(",")}`;
149
+ await connection.sendCommand(cmd);
150
+ return {
151
+ content: [
152
+ {
153
+ type: "text",
154
+ text: `Basic wave configured: ${cmd}`,
155
+ },
156
+ ],
157
+ };
158
+ }
159
+ catch (err) {
160
+ return {
161
+ content: [
162
+ {
163
+ type: "text",
164
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
165
+ },
166
+ ],
167
+ isError: true,
168
+ };
169
+ }
170
+ });
171
+ }
172
+ //# sourceMappingURL=basic-wave.js.map
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerBurstTools(server: McpServer): void;