Skip to content

Commit 2b24194

Browse files
committed
control protocol
1 parent ccac303 commit 2b24194

10 files changed

Lines changed: 893 additions & 5 deletions

ser2tcp/connection_control.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Connection control protocol - serial signal control via 0xFF escape"""
2+
3+
# Escape protocol:
4+
# FF FF = literal 0xFF byte
5+
# FF 00 = RTS low
6+
# FF 01 = RTS high
7+
# FF 10 = DTR low
8+
# FF 11 = DTR high
9+
# FF C0 = GET signals request
10+
# FF 8x = signal report (x = 6-bit bitmask)
11+
# bit 0: RTS, bit 1: DTR, bit 2: CTS, bit 3: DSR, bit 4: RI, bit 5: CD
12+
13+
ESCAPE = 0xFF
14+
CMD_RTS_LOW = 0x00
15+
CMD_RTS_HIGH = 0x01
16+
CMD_DTR_LOW = 0x10
17+
CMD_DTR_HIGH = 0x11
18+
CMD_GET_SIGNALS = 0xC0
19+
REPORT_BASE = 0x80
20+
REPORT_MASK = 0x3F
21+
22+
SIGNAL_NAMES = ('rts', 'dtr', 'cts', 'dsr', 'ri', 'cd')
23+
SIGNAL_BITS = {name: i for i, name in enumerate(SIGNAL_NAMES)}
24+
25+
26+
def wrap_control(connection_class, control_config):
27+
"""Wrap a connection class with control protocol handling.
28+
29+
Returns a new class that escapes 0xFF in outgoing data and parses
30+
escape sequences in incoming data for signal control commands.
31+
"""
32+
signals = control_config.get('signals', [])
33+
signal_set = set(s.lower() for s in signals)
34+
rts_enabled = bool(control_config.get('rts'))
35+
dtr_enabled = bool(control_config.get('dtr'))
36+
37+
class ControlConnection(connection_class):
38+
39+
def __init__(self, *args, **kwargs):
40+
super().__init__(*args, **kwargs)
41+
self._ctl_escape = False
42+
self._ctl_signals = signal_set
43+
self._ctl_rts = rts_enabled
44+
self._ctl_dtr = dtr_enabled
45+
46+
def send(self, data):
47+
"""Send data with 0xFF escaped"""
48+
return super().send(data.replace(b'\xff', b'\xff\xff'))
49+
50+
def send_signal_report(self, bitmask):
51+
"""Send signal report FF 8x"""
52+
# Filter bitmask to only configured signals
53+
filtered = 0
54+
for name in self._ctl_signals:
55+
bit = SIGNAL_BITS.get(name)
56+
if bit is not None and bitmask & (1 << bit):
57+
filtered |= (1 << bit)
58+
return super().send(
59+
bytes((ESCAPE, REPORT_BASE | (filtered & REPORT_MASK))))
60+
61+
def on_received(self, data):
62+
"""Parse escape sequences, forward clean data to serial"""
63+
data = bytearray(data)
64+
clean = bytearray()
65+
while data:
66+
if self._ctl_escape:
67+
self._ctl_escape = False
68+
cmd = data.pop(0)
69+
self._process_control_cmd(cmd, clean)
70+
continue
71+
if ESCAPE in data:
72+
index = data.index(ESCAPE)
73+
if index > 0:
74+
clean.extend(data[:index])
75+
del data[:index + 1]
76+
self._ctl_escape = True
77+
else:
78+
clean.extend(data)
79+
break
80+
if clean:
81+
self._serial.send(bytes(clean))
82+
83+
def _process_control_cmd(self, cmd, clean):
84+
"""Process a control command byte after 0xFF escape"""
85+
if cmd == ESCAPE:
86+
# FF FF = literal 0xFF
87+
clean.append(ESCAPE)
88+
elif cmd == CMD_RTS_LOW:
89+
if self._ctl_rts:
90+
self._serial.set_rts(False)
91+
elif cmd == CMD_RTS_HIGH:
92+
if self._ctl_rts:
93+
self._serial.set_rts(True)
94+
elif cmd == CMD_DTR_LOW:
95+
if self._ctl_dtr:
96+
self._serial.set_dtr(False)
97+
elif cmd == CMD_DTR_HIGH:
98+
if self._ctl_dtr:
99+
self._serial.set_dtr(True)
100+
elif cmd == CMD_GET_SIGNALS:
101+
bitmask = self._serial.get_signals()
102+
self.send_signal_report(bitmask)
103+
else:
104+
self._log.warning(
105+
"(%s): unknown control command: 0x%02x",
106+
self.address_str(), cmd)
107+
108+
ControlConnection.__name__ = 'Control' + connection_class.__name__
109+
ControlConnection.__qualname__ = 'Control' + connection_class.__qualname__
110+
return ControlConnection

ser2tcp/html/app.js

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ const MATCH_ATTRS = [
123123
'vid', 'pid', 'serial_number', 'manufacturer', 'product', 'location'
124124
];
125125
const PROTOCOLS = ['TCP', 'TELNET', 'SSL', 'SOCKET'];
126+
const CONTROL_SIGNALS = ['rts', 'dtr', 'cts', 'dsr', 'ri', 'cd'];
126127
const BAUDRATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200,
127128
230400, 460800, 921600];
128129
const BYTESIZES = {8: 'EIGHTBITS', 7: 'SEVENBITS', 6: 'SIXBITS', 5: 'FIVEBITS'};
@@ -220,6 +221,30 @@ function renderPortCard(port, index) {
220221
if (ser.baudrate) info += ser.baudrate + ' \u2014 ';
221222
info += connected ? 'connected' : 'disconnected';
222223
div.appendChild(el('p', info));
224+
// Signal indicators (clickable for RTS/DTR)
225+
if (port.signals) {
226+
const sigDiv = el('div', null, 'signal-indicators');
227+
CONTROL_SIGNALS.forEach(sig => {
228+
const on = port.signals[sig];
229+
const clickable = sig === 'rts' || sig === 'dtr';
230+
const badge = el('span', sig.toUpperCase(),
231+
'signal-badge ' + (on ? 'signal-on' : 'signal-off')
232+
+ (clickable ? ' signal-click' : ''));
233+
if (clickable) {
234+
badge.title = sig.toUpperCase() + ': click to toggle';
235+
badge.onclick = () => {
236+
badge.classList.add('signal-busy');
237+
api('PUT', '/api/ports/' + index + '/signals',
238+
{[sig]: !on}).then(() => loadPorts()).catch(e => {
239+
badge.classList.remove('signal-busy');
240+
if (e !== 'unauthorized') alert(e);
241+
});
242+
};
243+
}
244+
sigDiv.appendChild(badge);
245+
});
246+
div.appendChild(sigDiv);
247+
}
223248
// Show configured port or match
224249
if (ser.match) {
225250
const matchStr = Object.entries(ser.match)
@@ -232,7 +257,18 @@ function renderPortCard(port, index) {
232257
(port.servers || []).forEach((s, si) => {
233258
const proto = (s.protocol || 'tcp').toUpperCase();
234259
const addr = proto === 'SOCKET' ? s.address : s.address + ':' + s.port;
235-
const li = el('li', proto + ' \u2014 ' + addr);
260+
const li = el('li');
261+
li.appendChild(document.createTextNode(proto + ' \u2014 ' + addr));
262+
if (s.control) {
263+
const parts = [];
264+
if (s.control.rts) parts.push('RTS');
265+
if (s.control.dtr) parts.push('DTR');
266+
if (s.control.signals && s.control.signals.length)
267+
parts.push('report: ' + s.control.signals.map(
268+
s => s.toUpperCase()).join(', '));
269+
const label = parts.length ? parts.join(' | ') : 'escape only';
270+
li.appendChild(el('div', 'ctrl: ' + label, 'control-signals'));
271+
}
236272
const clients = s.connections || [];
237273
if (clients.length) {
238274
const cul = el('ul');
@@ -252,7 +288,7 @@ function renderPortCard(port, index) {
252288
});
253289
li.appendChild(cul);
254290
} else {
255-
li.appendChild(el('em', ' no connections'));
291+
li.appendChild(el('em', ' no connections', 'empty'));
256292
}
257293
ul.appendChild(li);
258294
});
@@ -374,6 +410,7 @@ function buildConfigFromStatus(port) {
374410
};
375411
if (s.port !== undefined) srv.port = s.port;
376412
if (s.ssl) srv.ssl = s.ssl;
413+
if (s.control) srv.control = s.control;
377414
return srv;
378415
});
379416
if (!config.servers.length) {
@@ -698,15 +735,108 @@ function renderServerBox(srv, index, total) {
698735
});
699736
box.appendChild(sslDiv);
700737

738+
// Control section
739+
const ctlDiv = el('div');
740+
ctlDiv.className = 'srv-control-fields';
741+
const ctl = srv.control || null;
742+
// Enable checkbox
743+
const ctlEnableRow = el('div', null, 'field-row');
744+
const ctlEnableLbl = document.createElement('label');
745+
ctlEnableLbl.className = 'ctl-signal-label';
746+
const ctlEnableCb = document.createElement('input');
747+
ctlEnableCb.type = 'checkbox';
748+
ctlEnableCb.className = 'ctl-enable';
749+
ctlEnableCb.checked = !!ctl;
750+
ctlEnableLbl.appendChild(ctlEnableCb);
751+
ctlEnableLbl.appendChild(document.createTextNode(' Control protocol'));
752+
ctlEnableRow.appendChild(ctlEnableLbl);
753+
ctlDiv.appendChild(ctlEnableRow);
754+
// Control details (shown when enabled)
755+
const ctlDetails = el('div');
756+
ctlDetails.className = 'ctl-details';
757+
// Protocol description
758+
const ctlDesc = el('p',
759+
'Binary escape protocol using 0xFF prefix. '
760+
+ 'All 0xFF bytes in data are escaped (FF FF). ',
761+
'ctl-desc');
762+
const ctlMoreBtn = el('a', 'Protocol reference');
763+
ctlMoreBtn.href = '#';
764+
ctlMoreBtn.className = 'detect-link';
765+
ctlMoreBtn.onclick = e => {
766+
e.preventDefault();
767+
const dlg = $('ctl-protocol-dlg');
768+
dlg.classList.toggle('hidden');
769+
};
770+
ctlDesc.appendChild(ctlMoreBtn);
771+
ctlDetails.appendChild(ctlDesc);
772+
// RTS/DTR write enable
773+
const ctlWriteRow = el('div', null, 'field-row');
774+
ctlWriteRow.appendChild(el('label', 'Allow set:'));
775+
['rts', 'dtr'].forEach(sig => {
776+
const lbl = document.createElement('label');
777+
lbl.className = 'ctl-signal-label';
778+
const cb = document.createElement('input');
779+
cb.type = 'checkbox';
780+
cb.className = 'ctl-write';
781+
cb.dataset.signal = sig;
782+
cb.checked = ctl ? !!ctl[sig] : false;
783+
lbl.appendChild(cb);
784+
lbl.appendChild(document.createTextNode(' ' + sig.toUpperCase()));
785+
ctlWriteRow.appendChild(lbl);
786+
});
787+
ctlDetails.appendChild(ctlWriteRow);
788+
// Report signals
789+
const ctlSigRow = el('div', null, 'field-row');
790+
ctlSigRow.appendChild(el('label', 'Report:'));
791+
const ctlSignals = ctl ? (ctl.signals || []) : [];
792+
CONTROL_SIGNALS.forEach(sig => {
793+
const lbl = document.createElement('label');
794+
lbl.className = 'ctl-signal-label';
795+
const cb = document.createElement('input');
796+
cb.type = 'checkbox';
797+
cb.className = 'ctl-signal';
798+
cb.dataset.signal = sig;
799+
cb.checked = ctlSignals.includes(sig);
800+
lbl.appendChild(cb);
801+
lbl.appendChild(document.createTextNode(' ' + sig.toUpperCase()));
802+
ctlSigRow.appendChild(lbl);
803+
});
804+
ctlDetails.appendChild(ctlSigRow);
805+
// Poll interval
806+
const pollRow = el('div', null, 'field-row');
807+
pollRow.appendChild(el('label', 'Poll interval:'));
808+
const pollSel = document.createElement('select');
809+
pollSel.className = 'ctl-poll-interval';
810+
const pollOptions = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
811+
const curPoll = ctl ? Math.round((ctl.poll_interval || 0.1) * 1000) : 100;
812+
pollOptions.forEach(ms => {
813+
const opt = document.createElement('option');
814+
opt.value = ms;
815+
opt.textContent = ms < 1000 ? ms + ' ms' : (ms / 1000) + ' s';
816+
if (ms === curPoll) opt.selected = true;
817+
pollSel.appendChild(opt);
818+
});
819+
pollRow.appendChild(pollSel);
820+
ctlDetails.appendChild(pollRow);
821+
ctlDiv.appendChild(ctlDetails);
822+
const updateCtlVisibility = () => {
823+
ctlDetails.classList.toggle('hidden', !ctlEnableCb.checked);
824+
};
825+
ctlEnableCb.onchange = updateCtlVisibility;
826+
updateCtlVisibility();
827+
box.appendChild(ctlDiv);
828+
701829
// Update visibility based on protocol
702830
const updateProtoFields = () => {
703831
const proto = protoSel.value;
704832
const isSocket = proto === 'SOCKET';
705833
const isSsl = proto === 'SSL';
834+
const isTelnet = proto === 'TELNET';
706835
addrLabel.textContent = isSocket ? 'Path:' : 'Address:';
707836
portLabel.classList.toggle('hidden', isSocket);
708837
portInput.classList.toggle('hidden', isSocket);
709838
sslDiv.classList.toggle('hidden', !isSsl);
839+
ctlDiv.classList.toggle('hidden', isTelnet);
710840
if (isSocket) {
711841
addrInput.value = addrInput.value === '0.0.0.0' ? '' : addrInput.value;
712842
}
@@ -815,6 +945,18 @@ function collectConfig() {
815945
if (cacerts) ssl.ca_certs = cacerts;
816946
if (Object.keys(ssl).length) srv.ssl = ssl;
817947
}
948+
if (proto !== 'telnet' && box.querySelector('.ctl-enable').checked) {
949+
const ctl = {};
950+
box.querySelectorAll('.ctl-write:checked').forEach(
951+
cb => { ctl[cb.dataset.signal] = true; });
952+
const signals = [];
953+
box.querySelectorAll('.ctl-signal:checked').forEach(
954+
cb => signals.push(cb.dataset.signal));
955+
if (signals.length) ctl.signals = signals;
956+
const pollMs = parseInt(box.querySelector('.ctl-poll-interval').value);
957+
if (pollMs) ctl.poll_interval = pollMs / 1000;
958+
srv.control = ctl;
959+
}
818960
config.servers.push(srv);
819961
});
820962

ser2tcp/html/index.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,38 @@ <h3>Add user</h3>
5151
</div>
5252
</div>
5353

54+
<div id="ctl-protocol-dlg" class="dialog-overlay hidden" onclick="if(event.target===this)this.classList.add('hidden')">
55+
<div class="dialog">
56+
<button class="btn-remove dialog-close" onclick="this.parentElement.parentElement.classList.add('hidden')">&times;</button>
57+
<h3>Control protocol reference</h3>
58+
<table>
59+
<thead><tr><th>Sequence</th><th>Direction</th><th>Description</th></tr></thead>
60+
<tbody>
61+
<tr><td><code>FF FF</code></td><td>&harr;</td><td>Literal 0xFF byte</td></tr>
62+
<tr><td><code>FF 00</code></td><td>&rarr; serial</td><td>RTS low</td></tr>
63+
<tr><td><code>FF 01</code></td><td>&rarr; serial</td><td>RTS high</td></tr>
64+
<tr><td><code>FF 10</code></td><td>&rarr; serial</td><td>DTR low</td></tr>
65+
<tr><td><code>FF 11</code></td><td>&rarr; serial</td><td>DTR high</td></tr>
66+
<tr><td><code>FF C0</code></td><td>&rarr; serial</td><td>Request signal report</td></tr>
67+
<tr><td><code>FF 8<em>x</em></code></td><td>&larr; client</td><td>Signal report (<em>x</em> = 6-bit bitmask)</td></tr>
68+
</tbody>
69+
</table>
70+
<h4>Signal report bitmask</h4>
71+
<table>
72+
<thead><tr><th>Bit</th><th>Signal</th></tr></thead>
73+
<tbody>
74+
<tr><td>0</td><td>RTS</td></tr>
75+
<tr><td>1</td><td>DTR</td></tr>
76+
<tr><td>2</td><td>CTS</td></tr>
77+
<tr><td>3</td><td>DSR</td></tr>
78+
<tr><td>4</td><td>RI</td></tr>
79+
<tr><td>5</td><td>CD</td></tr>
80+
</tbody>
81+
</table>
82+
<p>Report byte range: <code>0x80</code>&ndash;<code>0xBF</code>. Sent on signal change and on <code>FF C0</code> request.</p>
83+
</div>
84+
</div>
85+
5486
<script src="app.js"></script>
5587
</body>
5688
</html>

ser2tcp/html/style.css

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ button { padding: 0.4em 1em; border: none; border-radius: 4px;
2424
.login-box input { width: 100%; margin-bottom: 0.5em; box-sizing: border-box; }
2525
.login-box button { width: 100%; }
2626
.error { color: #e55; margin: 0.5em 0; }
27-
.hidden { display: none; }
27+
.hidden { display: none !important; }
2828
.badge { display: inline-block; padding: 0.1em 0.5em; border-radius: 3px;
2929
font-size: 0.8em; background: #e8e8e8; color: #666; }
3030
.badge-admin { background: #4a9eff; color: #fff; }
@@ -87,3 +87,33 @@ nav button:hover { color: #2a7edf; }
8787
.btn-disconnect:hover { color: #e55; }
8888
.detect-link { color: #4a9eff; text-decoration: none; cursor: pointer; }
8989
.detect-link:hover { text-decoration: underline; }
90+
.control-signals { color: #999; font-size: 0.85em; margin-top: 0.2em; }
91+
.ctl-signal-label { min-width: auto !important; display: inline-flex;
92+
align-items: center; gap: 0.2em; font-size: 0.9em; }
93+
.srv-control-fields { margin-top: 0.5em; padding-top: 0.5em;
94+
border-top: 1px dashed #e0e0e0; }
95+
.ctl-poll-interval { max-width: 8em; }
96+
.signal-indicators { display: flex; gap: 0.3em; margin: 0.4em 0; }
97+
.signal-badge { display: inline-block; padding: 0.1em 0.4em; border-radius: 3px;
98+
font-size: 0.75em; font-weight: bold; font-family: monospace; }
99+
.signal-on { background: #4c4; color: #fff; }
100+
.signal-off { background: #e0e0e0; color: #999; }
101+
.signal-click { cursor: pointer; }
102+
.signal-click:hover { opacity: 0.8; }
103+
.signal-busy { opacity: 0.5; pointer-events: none; }
104+
.ctl-desc { color: #888; font-size: 0.85em; margin: 0.3em 0; }
105+
.ctl-desc a { margin-left: 0.5em; }
106+
.dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0;
107+
background: rgba(0,0,0,0.4); display: flex; align-items: center;
108+
justify-content: center; z-index: 100; }
109+
.dialog { background: #fff; border-radius: 8px; padding: 1.5em;
110+
max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto;
111+
position: relative; box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
112+
.dialog h3 { margin-top: 0; }
113+
.dialog h4 { margin: 1em 0 0.3em; color: #555; }
114+
.dialog table { font-size: 0.9em; }
115+
.dialog code { background: #f0f0f0; padding: 0.1em 0.3em; border-radius: 3px;
116+
font-size: 0.9em; }
117+
.dialog p { font-size: 0.85em; color: #666; }
118+
.dialog-close { position: absolute; top: 0.5em; right: 0.5em;
119+
font-size: 1.3em; }

0 commit comments

Comments
 (0)