Skip to content

Commit 78c7de9

Browse files
committed
feat(cli): add RFC 2217 serial port forwarding support #44
close #44
1 parent c7928a9 commit 78c7de9

File tree

13 files changed

+712
-0
lines changed

13 files changed

+712
-0
lines changed

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@modelcontextprotocol/sdk": "^1.0.0",
5555
"@wokwi/client": "workspace:*",
5656
"@wokwi/diagram-lint": "workspace:*",
57+
"@wokwi/rfc2217": "workspace:*",
5758
"commander": "^12.1.0",
5859
"chalk": "^5.3.0",
5960
"chalk-template": "^1.1.0",

packages/cli/src/WokwiConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface WokwiTOML {
99
firmware: string;
1010
elf?: string;
1111
gdbServerPort?: number;
12+
rfc2217ServerPort?: number;
1213
};
1314
chip?: WokwiTOMLChip[];
1415
}

packages/cli/src/commands/simulate.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type SerialMonitorDataPayload,
66
} from '@wokwi/client';
77
import { DiagramLinter } from '@wokwi/diagram-lint';
8+
import { RFC2217Server } from '@wokwi/rfc2217';
89
import chalkTemplate from 'chalk-template';
910
import type { Command } from 'commander';
1011
import { createWriteStream, existsSync, readFileSync, writeFileSync } from 'fs';
@@ -208,6 +209,7 @@ async function runSimulation(projectPath: string, options: SimulateOptions, comm
208209
displayLintResults(lintResult, { quiet });
209210
}
210211

212+
const rfc2217ServerPort = config?.wokwi.rfc2217ServerPort;
211213
const chips = loadChips(config?.chip ?? [], rootDir);
212214

213215
const resolvedScenarioFile = scenarioFile ? path.resolve(rootDir, scenarioFile) : null;
@@ -248,6 +250,8 @@ async function runSimulation(projectPath: string, options: SimulateOptions, comm
248250
process.exit(1);
249251
};
250252

253+
let rfc2217Server: RFC2217Server | undefined;
254+
251255
try {
252256
await client.connected;
253257
await client.fileUpload('diagram.json', diagram);
@@ -313,13 +317,38 @@ async function runSimulation(projectPath: string, options: SimulateOptions, comm
313317

314318
await client.serialMonitorListen();
315319

320+
if (rfc2217ServerPort) {
321+
rfc2217Server = new RFC2217Server();
322+
rfc2217Server.on('error', (err) => {
323+
console.error(`RFC 2217 server error: ${err}`);
324+
});
325+
rfc2217Server.on('connected', () => {
326+
if (!quiet) {
327+
console.log('RFC 2217 client connected');
328+
}
329+
});
330+
rfc2217Server.on('data', (data) => {
331+
void client.serialMonitorWrite(data);
332+
});
333+
rfc2217Server.listen(rfc2217ServerPort);
334+
if (!quiet) {
335+
console.log(`RFC 2217 server listening on port ${rfc2217ServerPort}`);
336+
}
337+
}
338+
316339
client.listen('serial-monitor:data', (event: APIEvent<SerialMonitorDataPayload>) => {
317340
let { bytes } = event.payload;
318341
bytes = scenario?.processSerialBytes(bytes) ?? bytes;
319342
process.stdout.write(new Uint8Array(bytes));
320343

321344
serialLogStream?.write(Buffer.from(bytes));
322345
expectEngine.feed(bytes);
346+
347+
if (rfc2217Server) {
348+
for (const byte of bytes) {
349+
rfc2217Server.write(byte);
350+
}
351+
}
323352
});
324353

325354
client.listen('chips:log', (event: APIEvent<ChipsLogPayload>) => {
@@ -384,6 +413,7 @@ async function runSimulation(projectPath: string, options: SimulateOptions, comm
384413
}
385414
}
386415

416+
rfc2217Server?.dispose();
387417
client.close();
388418
}
389419
}

packages/rfc2217/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2023-2026 Uri Shaked
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

packages/rfc2217/README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# @wokwi/rfc2217
2+
3+
RFC 2217 (Telnet Com Port Control) server implementation. Enables serial port forwarding over TCP, allowing tools like PySerial and `socat` to connect to simulated devices via `rfc2217://localhost:<port>`.
4+
5+
## Installation
6+
7+
```bash
8+
npm install @wokwi/rfc2217
9+
```
10+
11+
## Usage
12+
13+
```typescript
14+
import { RFC2217Server } from '@wokwi/rfc2217';
15+
16+
const server = new RFC2217Server();
17+
18+
server.on('connected', () => {
19+
console.log('Client connected');
20+
});
21+
22+
server.on('data', (data) => {
23+
// Handle incoming serial data from the client
24+
console.log('Received:', data);
25+
});
26+
27+
server.on('control', ({ dtr, rts }) => {
28+
console.log('Control signals:', { dtr, rts });
29+
});
30+
31+
server.on('error', (err) => {
32+
console.error('Server error:', err);
33+
});
34+
35+
server.listen(4000);
36+
37+
// Send data to all connected clients
38+
server.write(0x48); // 'H'
39+
40+
// Clean up
41+
server.dispose();
42+
```
43+
44+
## Connecting
45+
46+
Once the server is listening, connect with any RFC 2217-compatible client:
47+
48+
```bash
49+
# PySerial
50+
python -c "import serial; s = serial.serial_for_url('rfc2217://localhost:4000')"
51+
52+
# socat
53+
socat - TCP:localhost:4000
54+
```
55+
56+
## License
57+
58+
[The MIT License (MIT)](LICENSE)

packages/rfc2217/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@wokwi/rfc2217",
3+
"version": "1.0.0",
4+
"description": "RFC 2217 (Telnet Com Port Control) server implementation",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"type": "module",
8+
"files": [
9+
"dist"
10+
],
11+
"scripts": {
12+
"build": "tsc",
13+
"clean": "rimraf dist",
14+
"test": "vitest --run",
15+
"test:watch": "vitest --watch"
16+
},
17+
"author": "Uri Shaked",
18+
"license": "ISC",
19+
"repository": {
20+
"type": "git",
21+
"url": "https://github.com/wokwi/wokwi-cli"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^24.9.2",
25+
"rimraf": "^5.0.0",
26+
"typescript": "^5.2.2",
27+
"vitest": "^3.0.5"
28+
}
29+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import EventEmitter from 'events';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { RFC2217Server } from './RFC2217Server.js';
4+
5+
function createMockSocket() {
6+
const socket = Object.assign(new EventEmitter(), {
7+
write: vi.fn(),
8+
end: vi.fn(),
9+
setNoDelay: vi.fn(),
10+
});
11+
return socket;
12+
}
13+
14+
describe('RFC2217Server', () => {
15+
it('should forward data from sockets to data event', () => {
16+
const server = new RFC2217Server();
17+
const received: Uint8Array[] = [];
18+
server.on('data', (data) => received.push(data));
19+
20+
const socket = createMockSocket();
21+
server.handleConnection(socket as any);
22+
23+
socket.emit('data', Buffer.from([0x48, 0x65]));
24+
25+
expect(received).toHaveLength(2);
26+
expect(received[0]).toEqual(new Uint8Array([0x48]));
27+
expect(received[1]).toEqual(new Uint8Array([0x65]));
28+
});
29+
30+
it('should track DTR/RTS state and deduplicate control events', () => {
31+
const server = new RFC2217Server();
32+
const controls: { rts: boolean; dtr: boolean }[] = [];
33+
server.on('control', (values) => controls.push({ ...values }));
34+
35+
const socket = createMockSocket();
36+
server.handleConnection(socket as any);
37+
38+
// DTR_ON
39+
socket.emit('data', Buffer.from([0xff, 0xfa, 44, 0x05, 0x08, 0xff, 0xf0]));
40+
// DTR_ON again (should be deduplicated)
41+
socket.emit('data', Buffer.from([0xff, 0xfa, 44, 0x05, 0x08, 0xff, 0xf0]));
42+
// RTS_ON
43+
socket.emit('data', Buffer.from([0xff, 0xfa, 44, 0x05, 0x0b, 0xff, 0xf0]));
44+
// DTR_OFF
45+
socket.emit('data', Buffer.from([0xff, 0xfa, 44, 0x05, 0x09, 0xff, 0xf0]));
46+
47+
expect(controls).toEqual([
48+
{ rts: false, dtr: true },
49+
{ rts: true, dtr: true },
50+
{ rts: true, dtr: false },
51+
]);
52+
});
53+
54+
it('should broadcast write to all connected sockets', () => {
55+
const server = new RFC2217Server();
56+
57+
const socket1 = createMockSocket();
58+
const socket2 = createMockSocket();
59+
server.handleConnection(socket1 as any);
60+
server.handleConnection(socket2 as any);
61+
62+
server.write(0x42);
63+
64+
expect(socket1.write).toHaveBeenCalledWith(Buffer.from([0x42]));
65+
expect(socket2.write).toHaveBeenCalledWith(Buffer.from([0x42]));
66+
});
67+
68+
it('should remove socket on close', () => {
69+
const server = new RFC2217Server();
70+
71+
const socket = createMockSocket();
72+
server.handleConnection(socket as any);
73+
expect(server.sockets.size).toBe(1);
74+
75+
socket.emit('close');
76+
expect(server.sockets.size).toBe(0);
77+
});
78+
79+
it('should dispose all sockets and server', () => {
80+
const server = new RFC2217Server();
81+
const serverClose = vi.fn(() => server.server);
82+
server.server.close = serverClose;
83+
84+
const socket = createMockSocket();
85+
server.handleConnection(socket as any);
86+
87+
server.dispose();
88+
89+
expect(socket.end).toHaveBeenCalled();
90+
expect(serverClose).toHaveBeenCalled();
91+
});
92+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import EventEmitter from 'events';
2+
import { createServer, Socket, type Server } from 'net';
3+
import { ControlCode, RFC2217Socket } from './RFC2217Socket.js';
4+
5+
export interface IControlPinValues {
6+
rts: boolean;
7+
dtr: boolean;
8+
}
9+
10+
export class RFC2217Server extends EventEmitter {
11+
readonly sockets = new Set<RFC2217Socket>();
12+
readonly server: Server;
13+
14+
private rts: boolean = false;
15+
private dtr: boolean = false;
16+
private disposed = false;
17+
18+
constructor() {
19+
super();
20+
this.server = createServer();
21+
this.server.on('connection', (socket) => this.handleConnection(socket));
22+
this.server.on('error', (e: Error) => {
23+
this.emit('error', e.toString());
24+
});
25+
}
26+
27+
listen(port = 4000) {
28+
this.server.listen(port);
29+
}
30+
31+
handleConnection(socket: Socket) {
32+
socket.setNoDelay(true);
33+
this.emit('connected');
34+
35+
const rfc2217 = new RFC2217Socket(socket);
36+
this.sockets.add(rfc2217);
37+
38+
rfc2217.on('data', (data) => {
39+
this.emit('data', new Uint8Array([data]));
40+
});
41+
42+
rfc2217.on('control', (value) => {
43+
if (value === ControlCode.RTS_ON && !this.rts) {
44+
this.rts = true;
45+
this.controlUpdated();
46+
}
47+
if (value === ControlCode.RTS_OFF && this.rts) {
48+
this.rts = false;
49+
this.controlUpdated();
50+
}
51+
if (value === ControlCode.DTR_ON && !this.dtr) {
52+
this.dtr = true;
53+
this.controlUpdated();
54+
}
55+
if (value === ControlCode.DTR_OFF && this.dtr) {
56+
this.dtr = false;
57+
this.controlUpdated();
58+
}
59+
});
60+
61+
socket.on('error', (err) => {
62+
console.error('RFC 2217 socket error', err);
63+
});
64+
65+
socket.on('close', () => {
66+
this.sockets.delete(rfc2217);
67+
});
68+
}
69+
70+
private controlUpdated() {
71+
this.emit('control', {
72+
rts: this.rts,
73+
dtr: this.dtr,
74+
});
75+
}
76+
77+
write(byte: number) {
78+
for (const socket of this.sockets) {
79+
try {
80+
socket.write(byte);
81+
} catch (err) {
82+
console.warn(err);
83+
this.sockets.delete(socket);
84+
}
85+
}
86+
}
87+
88+
dispose() {
89+
if (this.disposed) {
90+
return;
91+
}
92+
93+
for (const socket of this.sockets) {
94+
socket.close();
95+
}
96+
this.server.close();
97+
this.disposed = true;
98+
}
99+
}

0 commit comments

Comments
 (0)