Feature/source plugins #1
Executable
+400
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MIT
|
||||
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
"""A protocol mock of the Measurement Computing E-1608 Ethernet DAQ, for exercising udpscope
|
||||
without a board. It is the *contract* the `e1608` source is verified against until a board is
|
||||
attached, so its layouts are only as good as the two references they were taken from:
|
||||
|
||||
* MARTe2 `E1608DataSource` (~/Projects/marte2_projects/MCC_E1608_DataSource), the primary, and
|
||||
* the Go `uopi/internal/datasource/mcc` implementation, the cross-check.
|
||||
|
||||
./tools/mock_e1608.py --port 54211 [--code N] [--drop-after S] [--overrun-after S] [--log F]
|
||||
|
||||
Serves: UDP `D` discovery and `C` connection-code handshake on --port; the TCP command channel on
|
||||
--port; the TCP scan stream on --port+1 (it starts when the one filler byte follows the scan-start
|
||||
command). Frame: 0xDB, cmd, frame id, status, count LE16, payload, checksum (the whole frame sums
|
||||
to 0xFF mod 256). Reply: cmd | 0x80, same id.
|
||||
|
||||
UNVERIFIED -- every item below rests on a single reference, or the references disagree. They are
|
||||
the first places to look when a real board misbehaves:
|
||||
|
||||
* 0x31 counter reset: Go only (no payload, no reply payload).
|
||||
* Pacer: MARTe2 sends round(80e6 / (scan_rate * channels) - 1), the *aggregate* rate; Go sends the
|
||||
passed frequency. This mock follows MARTe2.
|
||||
* The scan-start options byte (trigger): none 0, rising 4, falling 8, high 12, low 16 -- MARTe2 only.
|
||||
* Handshake status 2 ("code ignored") is accepted by MARTe2 and refused by Go. The mock never sends it.
|
||||
* The first frame id: MARTe2 pre-increments (first frame is 1), Go starts at 0. The mock echoes it.
|
||||
* The maximum frame size (MARTe2: 1024 in total; Go: 1024 of payload). The mock enforces neither.
|
||||
* Agreed by both references (not in doubt): the filler byte after scan start, the scan port being
|
||||
command port + 1, DIFF wire channel = channel + 8.
|
||||
|
||||
Switches: --drop-after S closes both connections once, S seconds after the first command connection
|
||||
(logs DROP); --overrun-after S raises the overrun bit and stops the stream S seconds after a scan
|
||||
starts, once (logs OVERRUN); --code N is the connection code the board accepts (anything else gets
|
||||
status 1).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
CLOCK = 80e6
|
||||
PRODUCT_ID = 0x012F
|
||||
FS = [10.0, 5.0, 2.0, 1.0] # full scale per range code BIP10V, BIP5V, BIP2V, BIP1V
|
||||
|
||||
|
||||
def f32(x):
|
||||
return struct.unpack("<f", struct.pack("<f", x))[0]
|
||||
|
||||
|
||||
DF_CAL = [(f32(1.01 - 0.004 * r), f32(150.0 - 30.0 * r)) for r in range(4)]
|
||||
SE_CAL = [(f32(1.02 - 0.005 * r), f32(300.0 - 40.0 * r)) for r in range(4)]
|
||||
AO_CAL = [(f32(0.99 + 0.01 * c), f32(200.0 - 100.0 * c)) for c in range(2)]
|
||||
CAL_MEM = b"".join(struct.pack("<ff", s, i) for s, i in DF_CAL + SE_CAL + AO_CAL) # 80 bytes
|
||||
|
||||
|
||||
def close_quietly(sock):
|
||||
if sock is None:
|
||||
return
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def recv_exact(conn, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = conn.recv(n - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
class Board:
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
self.mac = bytes(int(x, 16) for x in args.mac.split(":"))
|
||||
self.lock = threading.RLock()
|
||||
self.cmd_conn = None
|
||||
self.scan_conn = None
|
||||
self.dout = 0
|
||||
self.dconf = 0xFF
|
||||
self.ao = [32768, 32768]
|
||||
self.queue = []
|
||||
self.pacer = 0
|
||||
self.armed = False
|
||||
self.streaming = False
|
||||
self.stream_gen = 0
|
||||
self.overrun = False
|
||||
self.overrun_done = False
|
||||
self.t_boot = time.monotonic()
|
||||
self.counter_t0 = self.t_boot
|
||||
self.first_conn = None
|
||||
self.logfile = open(args.log, "a", buffering=1) if args.log else None
|
||||
|
||||
def log(self, text):
|
||||
print("[mock] " + text, flush=True)
|
||||
if self.logfile:
|
||||
self.logfile.write(text + "\n")
|
||||
|
||||
# --- the pretend hardware -------------------------------------------------------------
|
||||
def din(self):
|
||||
pins = int((time.monotonic() - self.t_boot) * 4) & 0xFF
|
||||
return (pins & self.dconf) | (self.dout & ~self.dconf & 0xFF)
|
||||
|
||||
def counter(self):
|
||||
return int((time.monotonic() - self.counter_t0) * 5) & 0xFFFFFFFF
|
||||
|
||||
def raw(self, wire, rng, t):
|
||||
diff = wire >= 8
|
||||
ch = wire - 8 if diff else wire
|
||||
fs = FS[rng]
|
||||
dc = min(0.5 * (ch + 1), 0.8 * fs)
|
||||
v = np.clip(dc + 0.1 * np.sin(2.0 * np.pi * 5.0 * t), -0.98 * fs, 0.98 * fs)
|
||||
code = v * 32768.0 / fs + 32768.0
|
||||
slope, icpt = (DF_CAL if diff else SE_CAL)[rng]
|
||||
return np.clip(np.rint((code - icpt) / slope), 0, 65535)
|
||||
|
||||
# --- commands ---------------------------------------------------------------------------
|
||||
def handle(self, cmd, p):
|
||||
"""Returns (status, reply payload). Status 1 protocol, 2 parameter."""
|
||||
if cmd == 0x00:
|
||||
return 0, bytes([self.din()])
|
||||
if cmd == 0x02:
|
||||
return 0, bytes([self.dout])
|
||||
if cmd == 0x03:
|
||||
if len(p) < 1:
|
||||
return 2, b""
|
||||
self.dout = p[0]
|
||||
self.log("DOUT 0x%02x" % p[0])
|
||||
return 0, b""
|
||||
if cmd == 0x05:
|
||||
if len(p) < 1:
|
||||
return 2, b""
|
||||
self.dconf = p[0]
|
||||
self.log("DCONF 0x%02x" % p[0])
|
||||
return 0, b""
|
||||
if cmd == 0x10:
|
||||
return 0, struct.pack("<H", 32768)
|
||||
if cmd == 0x11:
|
||||
if len(p) < 9 or not self.queue:
|
||||
return 2, b""
|
||||
_count, pacer, opts = struct.unpack("<IIB", p[:9])
|
||||
with self.lock:
|
||||
self.pacer = pacer
|
||||
self.armed = True
|
||||
self.overrun = False
|
||||
self.log("SCAN_START pacer=%d options=%d channels=%d" % (pacer, opts, len(self.queue)))
|
||||
return 0, b""
|
||||
if cmd == 0x13:
|
||||
self.stop_stream(close_scan=bool(p and p[0]))
|
||||
self.log("SCAN_STOP")
|
||||
return 0, b""
|
||||
if cmd == 0x15:
|
||||
if not p:
|
||||
return 2, b""
|
||||
n = p[0]
|
||||
if n < 1 or n > 8 or len(p) != 1 + 2 * n:
|
||||
return 2, b""
|
||||
q = [(p[1 + 2 * i], p[2 + 2 * i]) for i in range(n)]
|
||||
if any(r > 3 for _, r in q):
|
||||
return 2, b""
|
||||
self.queue = q
|
||||
self.log("QUEUE " + " ".join("%d:%d" % e for e in q))
|
||||
return 0, b""
|
||||
if cmd == 0x20:
|
||||
return 0, struct.pack("<HH", *self.ao)
|
||||
if cmd == 0x21:
|
||||
if len(p) < 3 or p[0] > 1:
|
||||
return 2, b""
|
||||
ch, code = struct.unpack("<BH", p[:3])
|
||||
self.ao[ch] = code
|
||||
slope, icpt = AO_CAL[ch]
|
||||
volts = ((code - icpt) / slope - 32768.0) * 10.0 / 32768.0
|
||||
self.log("AOUT ch=%d code=%d volts=%.4f" % (ch, code, volts))
|
||||
return 0, b""
|
||||
if cmd == 0x30:
|
||||
return 0, struct.pack("<I", self.counter())
|
||||
if cmd == 0x31:
|
||||
self.counter_t0 = time.monotonic()
|
||||
self.log("COUNTER_RESET")
|
||||
return 0, b""
|
||||
if cmd == 0x40:
|
||||
if len(p) < 4:
|
||||
return 2, b""
|
||||
addr, count = struct.unpack("<HH", p[:4])
|
||||
data = CAL_MEM[addr:addr + count]
|
||||
if len(data) != count:
|
||||
return 2, b""
|
||||
return 0, data
|
||||
if cmd == 0x50:
|
||||
self.log("BLINK")
|
||||
return 0, b""
|
||||
if cmd == 0x52:
|
||||
st = 0
|
||||
if self.scan_conn is not None:
|
||||
st |= 1
|
||||
if self.streaming:
|
||||
st |= 2
|
||||
if self.overrun:
|
||||
st |= 4
|
||||
return 0, struct.pack("<H", st)
|
||||
return 1, b""
|
||||
|
||||
# --- streaming --------------------------------------------------------------------------
|
||||
def stop_stream(self, close_scan):
|
||||
with self.lock:
|
||||
self.stream_gen += 1
|
||||
self.streaming = False
|
||||
self.armed = False
|
||||
conn = None
|
||||
if close_scan:
|
||||
conn = self.scan_conn
|
||||
self.scan_conn = None
|
||||
close_quietly(conn)
|
||||
|
||||
def start_stream(self):
|
||||
for _ in range(100): # the scan socket's accept may trail the filler by a moment
|
||||
with self.lock:
|
||||
if self.scan_conn is not None:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
with self.lock:
|
||||
conn = self.scan_conn
|
||||
if conn is None or not self.armed:
|
||||
return
|
||||
self.armed = False
|
||||
self.streaming = True
|
||||
self.stream_gen += 1
|
||||
gen = self.stream_gen
|
||||
queue = list(self.queue)
|
||||
pacer = self.pacer
|
||||
threading.Thread(target=self.stream, args=(gen, conn, queue, pacer), daemon=True).start()
|
||||
|
||||
def stream(self, gen, conn, queue, pacer):
|
||||
nq = len(queue)
|
||||
agg = CLOCK / (pacer + 1) if pacer > 0 else 1000.0 * nq # external clock: 1 kS/s each
|
||||
rate = agg / nq
|
||||
t0 = time.monotonic()
|
||||
sent = 0
|
||||
ov_at = None
|
||||
if self.args.overrun_after > 0 and not self.overrun_done:
|
||||
ov_at = t0 + self.args.overrun_after
|
||||
self.log("STREAM rate=%.1f channels=%d" % (rate, nq))
|
||||
try:
|
||||
while self.stream_gen == gen:
|
||||
now = time.monotonic()
|
||||
if ov_at is not None and now >= ov_at:
|
||||
with self.lock:
|
||||
if self.stream_gen == gen:
|
||||
self.overrun = True
|
||||
self.overrun_done = True
|
||||
self.streaming = False
|
||||
self.log("OVERRUN")
|
||||
return
|
||||
due = min(int((now - t0) * rate) - sent, 65536)
|
||||
if due > 0:
|
||||
t = np.arange(sent, sent + due) / rate
|
||||
cols = [self.raw(w, r, t) for (w, r) in queue]
|
||||
conn.sendall(np.stack(cols, axis=1).astype("<u2").tobytes())
|
||||
sent += due
|
||||
time.sleep(0.005)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
with self.lock:
|
||||
if self.stream_gen == gen:
|
||||
self.streaming = False
|
||||
|
||||
def drop(self):
|
||||
self.log("DROP")
|
||||
with self.lock:
|
||||
self.stream_gen += 1
|
||||
self.streaming = False
|
||||
self.armed = False
|
||||
conns = [self.cmd_conn, self.scan_conn]
|
||||
self.cmd_conn = None
|
||||
self.scan_conn = None
|
||||
for c in conns:
|
||||
close_quietly(c)
|
||||
|
||||
# --- servers ----------------------------------------------------------------------------
|
||||
def serve_udp(self, port):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("", port))
|
||||
while True:
|
||||
data, addr = sock.recvfrom(64)
|
||||
if data[:1] == b"D":
|
||||
reply = b"D" + self.mac + struct.pack("<HH", PRODUCT_ID, 0x0100)
|
||||
reply += b"E-1608".ljust(16, b"\0") + struct.pack("<H", port)
|
||||
sock.sendto(reply.ljust(64, b"\0"), addr)
|
||||
elif data[:1] == b"C" and len(data) >= 5:
|
||||
(code,) = struct.unpack("<I", data[1:5])
|
||||
status = 0 if code == self.args.code else 1
|
||||
self.log("HANDSHAKE code=%d status=%d" % (code, status))
|
||||
sock.sendto(b"C" + bytes([status]), addr)
|
||||
|
||||
def serve_scan_port(self, port):
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("", port))
|
||||
srv.listen(4)
|
||||
while True:
|
||||
conn, _ = srv.accept()
|
||||
with self.lock:
|
||||
old = self.scan_conn
|
||||
self.scan_conn = conn
|
||||
close_quietly(old)
|
||||
|
||||
def serve_cmd(self, conn):
|
||||
with self.lock:
|
||||
old = self.cmd_conn
|
||||
self.cmd_conn = conn
|
||||
first = self.first_conn is None
|
||||
if first:
|
||||
self.first_conn = time.monotonic()
|
||||
close_quietly(old)
|
||||
if first and self.args.drop_after > 0:
|
||||
threading.Timer(self.args.drop_after, self.drop).start()
|
||||
try:
|
||||
while True:
|
||||
b = conn.recv(1)
|
||||
if not b:
|
||||
break
|
||||
if b[0] == 0x00:
|
||||
self.start_stream() # the filler byte after scan start
|
||||
continue
|
||||
if b[0] != 0xDB:
|
||||
continue
|
||||
rest = recv_exact(conn, 5)
|
||||
if rest is None:
|
||||
break
|
||||
cmd, fid, _status, count = struct.unpack("<BBBH", rest)
|
||||
body = recv_exact(conn, count + 1)
|
||||
if body is None:
|
||||
break
|
||||
frame = b"\xDB" + rest + body
|
||||
if (sum(frame) & 0xFF) == 0xFF:
|
||||
status, reply = self.handle(cmd, body[:count])
|
||||
else:
|
||||
status, reply = 1, b""
|
||||
out = struct.pack("<BBBBH", 0xDB, cmd | 0x80, fid, status, len(reply)) + reply
|
||||
out += bytes([(0xFF - sum(out)) & 0xFF])
|
||||
conn.sendall(out)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
with self.lock:
|
||||
mine = self.cmd_conn is conn
|
||||
if mine:
|
||||
self.cmd_conn = None
|
||||
if mine:
|
||||
self.stop_stream(close_scan=True)
|
||||
close_quietly(conn)
|
||||
|
||||
def serve_cmd_port(self, port):
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("", port))
|
||||
srv.listen(4)
|
||||
while True:
|
||||
conn, _ = srv.accept()
|
||||
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
threading.Thread(target=self.serve_cmd, args=(conn,), daemon=True).start()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="E-1608 protocol mock")
|
||||
ap.add_argument("--port", type=int, default=54211)
|
||||
ap.add_argument("--mac", default="00:80:2f:00:16:08")
|
||||
ap.add_argument("--code", type=int, default=0, help="connection code the board accepts")
|
||||
ap.add_argument("--drop-after", type=float, default=0.0, help="close both links once, S s after the first command connection")
|
||||
ap.add_argument("--overrun-after", type=float, default=0.0, help="raise the overrun bit S s after a scan starts, once")
|
||||
ap.add_argument("--log", default="", help="append event lines to this file")
|
||||
args = ap.parse_args()
|
||||
board = Board(args)
|
||||
for target in (board.serve_udp, board.serve_scan_port, board.serve_cmd_port):
|
||||
port = args.port + 1 if target == board.serve_scan_port else args.port
|
||||
threading.Thread(target=target, args=(port,), daemon=True).start()
|
||||
print("mock_e1608: listening on port %d (scan %d)" % (args.port, args.port + 1), flush=True)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user