forked from arti/vt1200
842 lines
36 KiB
Python
842 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
"""vidsend.py -- the host sender: paced strip frames and the status back-channel.
|
|
|
|
One frame *n* leaves at ``t0 + n/fps`` (an absolute schedule, so a slow frame is
|
|
caught up on rather than shifting every later frame), the frame is chunked at
|
|
1450 bytes and carried in Ethernet frames of the stream's own type
|
|
(:data:`harness.udpstream.frame.ETHERTYPE`), and every 30 frames one CSV line is
|
|
printed. In the other direction the script listens for the terminal's
|
|
``KIND_STATUS`` frames and decodes them.
|
|
|
|
sudo python3 harness/udpstream/vidsend.py --iface vt1200 --frames 200
|
|
sudo python3 harness/udpstream/vidsend.py --iface vt1200 --tiles --frames 300
|
|
python3 harness/udpstream/vidsend.py --dry-run --frames 200 # no NIC
|
|
|
|
``--tiles`` sends the cached tile stream instead of strip frames: the 32 KB
|
|
dictionary in chunks (``tiledict.dict_chunks``), then one ``TILES_FULL`` or
|
|
``TILES_DELTA`` per clip frame from the cache ``make_tiles.py`` wrote. The
|
|
keyframe decision is live, not a replay of the cache: ``keyframe_due(frame_no,
|
|
drops, seen_drops)`` sees the terminal's *cumulative* ``drops`` from each status
|
|
frame, so a reported loss forces a full frame at the next opportunity rather
|
|
than waiting for the 60-frame cadence. A clip shorter than ``--frames`` loops,
|
|
and the cadence keeps counting across the loop, which is what makes a 6,300-
|
|
frame soak from a 30-frame cache a real run and not 210 restarts.
|
|
|
|
Exit status is 0 only when a status frame was received and the last one reports
|
|
``drops == 0`` and ``crc_err == 0``. "No status" is a failure: the two
|
|
conditions were never observed, and a sender that cannot see the terminal must
|
|
not claim the terminal is happy.
|
|
|
|
WHAT THIS CANNOT PROVE HERE. The MAME driver creates no
|
|
``device_network_interface`` (ruling R11), so the emulated LANCE is not attached
|
|
to a host NIC: this script cannot be pointed at the emulator. It is exercised
|
|
three ways instead, and none of them is a network run:
|
|
|
|
* the pacing, chunking, padding and status decode are unit-tested through an
|
|
injected ``send`` callable and an injected clock
|
|
(harness/udpstream/tests/test_vidsend.py);
|
|
* ``--dry-run`` runs the real 200-frame, 30 fps schedule against a counting
|
|
transport, which is what makes a wall-clock pacing check possible without a
|
|
socket -- it prints ``RESULT DRY-RUN`` and never claims a delivery;
|
|
* the terminal's own status emission is checked in MAME
|
|
(``VT1200_MODE=stream harness/mame/run-vidstream.sh``), where the payload's
|
|
LANCE transmit path is exercised and the bytes are decoded by
|
|
``harness/mame/vidstream_status_check.py`` with the same wire format this
|
|
script parses.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
if __package__ in (None, ""): # run as a script, not a module
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
|
|
os.path.abspath(__file__)))))
|
|
|
|
from harness.udpstream import make_tiles # noqa: E402
|
|
from harness.udpstream.frame import ( # noqa: E402
|
|
BROADCAST, ETHERTYPE, HEADER_SIZE, KIND_STATUS, MAGIC, STATUS_SIZE,
|
|
ethernet_frame, pack_header, unpack_header, unpack_status)
|
|
from harness.udpstream.tiledict import ( # noqa: E402
|
|
DELTA_HDR, KEYFRAME_EVERY, TILES_PER_SCREEN, delta_frame,
|
|
dict_chunks, keyframe_due)
|
|
from harness.udpstream.vidsrc import ( # noqa: E402
|
|
CHUNK, MAX_ROWS, RING_DATAGRAMS, ROW_BYTES, StripSource)
|
|
|
|
__all__ = [
|
|
"CSV_HEADER", "CSV_HEADER_TILES", "Sender", "TileSender", "bars", "split",
|
|
"PATTERNS", "decode_status_frame", "RawEthernet", "parse_mac",
|
|
]
|
|
|
|
#: One CSV line is printed every this many frames.
|
|
REPORT_EVERY = 30
|
|
|
|
#: The strip CSV columns, in this order: frame number, elapsed seconds, achieved
|
|
#: fps, bytes handed to the transport so far. The CSV goes to stdout and the
|
|
#: prose (status lines, the verdict) to stderr, so a run's stdout is a clean
|
|
#: table.
|
|
CSV_HEADER = "frame,elapsed_s,fps,bytes"
|
|
|
|
#: The tile CSV adds what plan step 3 asks for: which kind went out, how many
|
|
#: payload bytes it carried, how many tiles changed, and whether the frame was
|
|
#: a keyframe. The first four columns are the strip table's, so a run can be
|
|
#: processed by the same tools.
|
|
CSV_HEADER_TILES = ("frame,elapsed_s,fps,bytes,kind,payload_bytes,"
|
|
"changed_tiles,keyframe")
|
|
|
|
#: Bars per screen: a source row is ink while ``(r + n) % 16 < 8``.
|
|
BAR_PERIOD = 16
|
|
|
|
|
|
# --- patterns -------------------------------------------------------------
|
|
|
|
def bars(rows: int, frame_no: int) -> bytes:
|
|
"""Packed rows of horizontal bars, drifting up one row per frame.
|
|
|
|
Two row classes per frame (ink and blank), so a decoder that reused one
|
|
source row cannot produce the picture, and the pattern moves between
|
|
frames, so a frozen stream is distinguishable from a live one.
|
|
"""
|
|
out = bytearray(rows * ROW_BYTES)
|
|
for r in range(rows):
|
|
if (r + frame_no) % BAR_PERIOD < BAR_PERIOD // 2:
|
|
start = r * ROW_BYTES
|
|
out[start:start + ROW_BYTES] = b"\xff" * ROW_BYTES
|
|
return bytes(out)
|
|
|
|
|
|
def split(rows: int, frame_no: int) -> bytes:
|
|
"""Packed rows of a split field with a vertical edge that moves per frame.
|
|
|
|
A second pattern whose answer is known analytically: the left 640 pixels of
|
|
every row are ink, and the right side is ink only above a moving horizon.
|
|
"""
|
|
out = bytearray(rows * ROW_BYTES)
|
|
horizon = (frame_no * 3) % max(rows, 1)
|
|
for r in range(rows):
|
|
start = r * ROW_BYTES
|
|
out[start:start + ROW_BYTES // 2] = b"\xff" * (ROW_BYTES // 2)
|
|
if r < horizon:
|
|
out[start + ROW_BYTES // 2:start + ROW_BYTES] = \
|
|
b"\xff" * (ROW_BYTES // 2)
|
|
return bytes(out)
|
|
|
|
|
|
PATTERNS = {"bars": bars, "split": split}
|
|
|
|
#: The largest VS frame the tile path can put on the wire: a delta that names
|
|
#: every tile is ``count u16`` plus 3 bytes per entry, the most the format can
|
|
#: carry. The dictionary goes out as one-datagram chunks and never uses
|
|
#: ``--chunk``.
|
|
MAX_TILE_FRAME = HEADER_SIZE + DELTA_HDR + 3 * TILES_PER_SCREEN
|
|
|
|
|
|
def min_chunk(frame_bytes: int) -> int:
|
|
"""Smallest ``--chunk`` whose frame fits the terminal's RX ring.
|
|
|
|
The ring holds :data:`RING_DATAGRAMS` descriptors and cannot receive a frame
|
|
that needs more; the payload drops it and counts ``RX_RING_DROPS``. One
|
|
frame of ``frame_bytes`` split at ``chunk`` needs ``ceil(frame_bytes /
|
|
chunk)`` datagrams, so the smallest legal chunk is the ceiling of the frame
|
|
size over the ring size.
|
|
"""
|
|
return (frame_bytes + RING_DATAGRAMS - 1) // RING_DATAGRAMS
|
|
|
|
|
|
# --- the status back-channel ---------------------------------------------
|
|
|
|
def decode_status_frame(data: bytes):
|
|
"""Decode one received Ethernet frame, or return None.
|
|
|
|
None means "not a status frame of ours": a different ethertype, too short,
|
|
not a ``VS`` frame, or a different kind. A status frame whose header CRC is
|
|
wrong is *returned* with ``crc_ok`` False, so the caller can count it
|
|
instead of silently treating it as noise.
|
|
"""
|
|
if len(data) < 14 + HEADER_SIZE + STATUS_SIZE:
|
|
return None
|
|
if data[12:14] != struct.pack(">H", ETHERTYPE):
|
|
return None
|
|
body = data[14:]
|
|
try:
|
|
h = unpack_header(body)
|
|
except ValueError:
|
|
return None
|
|
if h["magic"] != MAGIC or h["kind"] != KIND_STATUS:
|
|
return None
|
|
if h["payload_len"] != STATUS_SIZE:
|
|
return None
|
|
out = unpack_status(body[HEADER_SIZE:HEADER_SIZE + STATUS_SIZE])
|
|
out["kind"] = h["kind"]
|
|
out["frame_no"] = h["frame_no"]
|
|
out["crc_ok"] = h["crc_ok"]
|
|
return out
|
|
|
|
|
|
# --- pacing ---------------------------------------------------------------
|
|
|
|
class Sender:
|
|
"""Send ``rows``-row strip frames at a fixed rate and collect status frames.
|
|
|
|
Everything that would touch the world is injected, so the schedule and the
|
|
verdict can be tested without a socket:
|
|
|
|
``transport(data: bytes)`` one Ethernet frame, header included
|
|
``receive()`` zero or more received Ethernet frames
|
|
``clock()`` / ``sleep(s)`` the time base the schedule is measured on
|
|
"""
|
|
|
|
def __init__(self, transport, src, receive=None, rows=128, stretch=8,
|
|
fps=30.0, frames=None, pattern="bars", chunk=CHUNK,
|
|
dst=BROADCAST, clock=time.monotonic, sleep=time.sleep,
|
|
report_every=REPORT_EVERY, status_wait=1.0, out=None,
|
|
err=None):
|
|
if pattern not in PATTERNS:
|
|
raise ValueError("unknown pattern %r; choose from %s"
|
|
% (pattern, ", ".join(sorted(PATTERNS))))
|
|
if chunk < HEADER_SIZE + 1:
|
|
raise ValueError("chunk %d is too small for a frame" % chunk)
|
|
if fps < 0:
|
|
raise ValueError("fps must be >= 0 (0 = unpaced): %r" % (fps,))
|
|
self.source = StripSource(rows, stretch)
|
|
need = min_chunk(HEADER_SIZE + rows * ROW_BYTES)
|
|
if chunk < need:
|
|
raise ValueError(
|
|
"chunk %d would split a %d-row frame (%d bytes) across more "
|
|
"than the terminal's %d-descriptor RX ring; chunk must be at "
|
|
"least %d"
|
|
% (chunk, rows, HEADER_SIZE + rows * ROW_BYTES,
|
|
RING_DATAGRAMS, need))
|
|
self.pattern = pattern
|
|
self.rows = rows
|
|
self.stretch = stretch
|
|
self.fps = float(fps)
|
|
self.frames = frames
|
|
self.chunk = chunk
|
|
self.dst = dst
|
|
self.src = src
|
|
self.transport = transport
|
|
self.receive = receive
|
|
self.clock = clock
|
|
self.sleep = sleep
|
|
self.report_every = report_every
|
|
self.status_wait = status_wait
|
|
self.out = out if out is not None else sys.stdout
|
|
self.err = err if err is not None else sys.stderr
|
|
self.frame_offset = 0
|
|
self.sent_frames = 0
|
|
self.sent_datagrams = 0
|
|
self.sent_bytes = 0
|
|
self.statuses = []
|
|
self.bad_status = 0
|
|
self.t0 = 0.0
|
|
|
|
# -- output --------------------------------------------------------
|
|
def say(self, text):
|
|
"""One CSV line: stdout, because it is a table."""
|
|
print(text, file=self.out, flush=True)
|
|
|
|
def log(self, text):
|
|
"""Prose: stderr, so it cannot be mistaken for a CSV row."""
|
|
print(text, file=self.err, flush=True)
|
|
|
|
# -- one frame -----------------------------------------------------
|
|
def frame_bytes(self, n: int) -> bytes:
|
|
"""The complete wire frame (header + body) for frame *n*."""
|
|
body = PATTERNS[self.pattern](self.rows, n)
|
|
no = (self.frame_offset + n) & 0xFFFF
|
|
return b"".join(self.source.encode(no, body))
|
|
|
|
def datagrams(self, n: int) -> list[bytes]:
|
|
"""The frame split into datagrams, only the last padded to the minimum.
|
|
|
|
Padding matters: the 60-byte Ethernet minimum must be reached at the
|
|
*end* of a frame. Padding a middle chunk would insert bytes the sender
|
|
never meant between two chunks of one frame, and the receiver would see
|
|
a body shifted by however many pads were inserted.
|
|
"""
|
|
frame = self.frame_bytes(n)
|
|
chunks = [frame[i:i + self.chunk]
|
|
for i in range(0, len(frame), self.chunk)]
|
|
out = []
|
|
for i, chunk in enumerate(chunks):
|
|
out.append(ethernet_frame(chunk, src=self.src, dst=self.dst,
|
|
pad=(i == len(chunks) - 1)))
|
|
return out
|
|
|
|
def send_frame(self, n: int):
|
|
for data in self.datagrams(n):
|
|
self.transport(data)
|
|
self.sent_datagrams += 1
|
|
self.sent_bytes += len(data)
|
|
self.sent_frames += 1
|
|
|
|
# -- status --------------------------------------------------------
|
|
def poll(self):
|
|
if self.receive is None:
|
|
return
|
|
for data in (self.receive() or [])[:64]:
|
|
st = decode_status_frame(data)
|
|
if st is None:
|
|
continue
|
|
if not st["crc_ok"]:
|
|
self.bad_status += 1
|
|
self.log("status: header CRC bad, ignored (frames_rx=%d)"
|
|
% st["frames_rx"])
|
|
continue
|
|
self.statuses.append(st)
|
|
self.log("status %d: frame_no=%d frames_rx=%d crc_err=%d drops=%d "
|
|
"csr0=0x%04X heap_free_kb=%d"
|
|
% (len(self.statuses), st["frame_no"], st["frames_rx"],
|
|
st["crc_err"], st["drops"], st["csr0"],
|
|
st["heap_free_kb"]))
|
|
|
|
# -- the run -------------------------------------------------------
|
|
def run(self, judge=True) -> int:
|
|
"""Send the run; ``judge`` False skips the verdict (the dry run's case)."""
|
|
self.t0 = self.clock()
|
|
if self.report_every:
|
|
self.say(CSV_HEADER)
|
|
n = 0
|
|
try:
|
|
while self.frames is None or n < self.frames:
|
|
self.wait_for(n)
|
|
self.send_frame(n)
|
|
n += 1
|
|
self.poll()
|
|
if self.report_every and n % self.report_every == 0:
|
|
self.report(n)
|
|
except KeyboardInterrupt:
|
|
self.log("interrupted after %d frames" % n)
|
|
if judge:
|
|
self.drain()
|
|
return self.verdict()
|
|
return 0
|
|
|
|
def wait_for(self, n: int):
|
|
"""Sleep until ``t0 + n/fps``; the first frame does not sleep."""
|
|
if self.fps <= 0:
|
|
return
|
|
delta = self.t0 + n / self.fps - self.clock()
|
|
if delta > 0:
|
|
self.sleep(delta)
|
|
|
|
def report(self, n: int):
|
|
elapsed = self.clock() - self.t0
|
|
rate = n / elapsed if elapsed > 0 else 0.0
|
|
self.say("%d,%.3f,%.2f,%d" % (n, elapsed, rate, self.sent_bytes))
|
|
|
|
def drain(self, limit=None):
|
|
"""Keep listening after the last frame, for a status still in flight.
|
|
|
|
The terminal reports every 64 accepted frames, so the report for the
|
|
last few frames of a short run may never come; the wait is bounded and
|
|
ends early only when a report covers everything that was sent.
|
|
"""
|
|
if self.receive is None or self.status_wait <= 0:
|
|
return
|
|
deadline = self.clock() + (self.status_wait if limit is None else limit)
|
|
while self.clock() < deadline:
|
|
self.poll()
|
|
if (self.statuses and self.frames is not None
|
|
and self.statuses[-1]["frames_rx"] >= self.frames):
|
|
return
|
|
self.sleep(0.02)
|
|
|
|
def verdict(self) -> int:
|
|
elapsed = self.clock() - self.t0
|
|
self.log("sent %d frames in %d datagrams, %d bytes, %.1f fps"
|
|
% (self.sent_frames, self.sent_datagrams, self.sent_bytes,
|
|
(self.sent_frames / elapsed) if elapsed > 0 else 0.0))
|
|
if not self.statuses:
|
|
self.log("FAIL: no status frame received (the terminal said "
|
|
"nothing, so drops and crc_err are unknown)")
|
|
return 1
|
|
last = self.statuses[-1]
|
|
if self.bad_status:
|
|
self.log("FAIL: %d status frame(s) had a bad header CRC"
|
|
% self.bad_status)
|
|
return 1
|
|
if last["drops"] or last["crc_err"]:
|
|
self.log("FAIL: terminal reports drops=%d crc_err=%d"
|
|
% (last["drops"], last["crc_err"]))
|
|
return 1
|
|
lag = (self.sent_frames - last["frames_rx"]
|
|
if self.frames is not None else 0)
|
|
self.log("PASS: %d status frames, last frames_rx=%d "
|
|
"(drops=0 crc_err=0), %d frame(s) sent since that report"
|
|
% (len(self.statuses), last["frames_rx"], max(lag, 0)))
|
|
return 0
|
|
|
|
|
|
# --- tile mode: the cached stream -----------------------------------------
|
|
|
|
class TileSender:
|
|
"""Send a ``make_tiles.py`` cache: the dictionary, then full/delta frames.
|
|
|
|
Everything the world touches is injected exactly as in :class:`Sender`, so
|
|
the plan, the wire bytes and the verdict are testable without a socket.
|
|
|
|
The frames come from the cache's ``indices`` rather than from its
|
|
``stream`` records: the sender has to *decide* the keyframe at send time,
|
|
because only it can see the terminal's status frame. With no drops the
|
|
decision reproduces the cached plan byte for byte -- ``test_vidsend.py``
|
|
pins that -- and with a drop it inserts a full frame the cache never
|
|
planned, which is the whole point of carrying ``drops`` into
|
|
:func:`tiledict.keyframe_due`.
|
|
|
|
A clip shorter than the run loops. ``frame_no`` keeps counting, so the
|
|
cadence and the loop are one timeline: frame 60 is a keyframe whether it is
|
|
clip frame 0 or clip frame 30.
|
|
"""
|
|
|
|
def __init__(self, cache, transport, src, receive=None, dst=BROADCAST,
|
|
fps=30.0, frames=None, chunk=CHUNK, clock=time.monotonic,
|
|
sleep=time.sleep, report_every=1, status_wait=1.0,
|
|
every=KEYFRAME_EVERY, out=None, err=None):
|
|
if chunk < HEADER_SIZE + 1:
|
|
raise ValueError("chunk %d is too small for a frame" % chunk)
|
|
need = min_chunk(MAX_TILE_FRAME)
|
|
if chunk < need:
|
|
raise ValueError(
|
|
"chunk %d would split a full-screen delta (%d bytes) across "
|
|
"more than the terminal's %d-descriptor RX ring; chunk must "
|
|
"be at least %d"
|
|
% (chunk, MAX_TILE_FRAME, RING_DATAGRAMS, need))
|
|
if fps < 0:
|
|
raise ValueError("fps must be >= 0 (0 = unpaced): %r" % (fps,))
|
|
if not cache.indices:
|
|
raise ValueError("the tile cache has no frames")
|
|
self.cache = cache
|
|
self.transport = transport
|
|
self.receive = receive
|
|
self.src = src
|
|
self.dst = dst
|
|
self.fps = float(fps)
|
|
self.frames = frames
|
|
self.chunk = chunk
|
|
self.every = every
|
|
self.clock = clock
|
|
self.sleep = sleep
|
|
self.report_every = report_every
|
|
self.status_wait = status_wait
|
|
self.out = out if out is not None else sys.stdout
|
|
self.err = err if err is not None else sys.stderr
|
|
|
|
self.t0 = 0.0
|
|
self.frames_done = 0 # clip frames the schedule has produced
|
|
self.sent_frames = 0 # frames actually put on the wire
|
|
self.full_frames = 0
|
|
self.delta_frames = 0
|
|
self.same_frames = 0
|
|
self.sent_datagrams = 0
|
|
self.sent_bytes = 0
|
|
self.dictionary_sent = 0 # datagrams the dictionary upload took
|
|
self.keyframes = [] # the clip frame numbers sent as TILES_FULL
|
|
self.previous = None # the last index frame the terminal has
|
|
self.drops = 0 # the terminal's cumulative drops, latest
|
|
self.seen_drops = 0 # the value the last decision consumed
|
|
self.statuses = []
|
|
self.bad_status = 0
|
|
self.interrupted = False
|
|
|
|
# -- output --------------------------------------------------------
|
|
def say(self, text):
|
|
print(text, file=self.out, flush=True)
|
|
|
|
def log(self, text):
|
|
print(text, file=self.err, flush=True)
|
|
|
|
# -- the wire ------------------------------------------------------
|
|
def dictionary_datagrams(self) -> list[bytes]:
|
|
"""The 32 KB dictionary as Ethernet frames, offsets ascending.
|
|
|
|
A chunk's own framing is the protocol's (``tiledict.MAX_PAYLOAD``), not
|
|
this sender's ``--chunk``: the two are different sizes for different
|
|
reasons. Every chunk is one datagram, so each is padded to the
|
|
Ethernet minimum on its own -- none is ever in the middle of a frame.
|
|
"""
|
|
return [ethernet_frame(packet, src=self.src, dst=self.dst, pad=True)
|
|
for packet in dict_chunks(self.cache.dictionary)]
|
|
|
|
def frame_plan(self, n: int):
|
|
"""``(kind, payload, changed, keyframe)`` for clip frame ``n``.
|
|
|
|
``keyframe`` is the decision, ``kind`` the consequence: a full frame is
|
|
always a keyframe, a delta is never one, and SAME (nothing changed
|
|
since the previous frame in the loop) carries no payload at all.
|
|
"""
|
|
current = self.cache.indices[n % len(self.cache.indices)]
|
|
changed = 0 if self.previous is None else sum(
|
|
1 for a, b in zip(self.previous, current) if a != b)
|
|
if keyframe_due(n, self.drops, self.seen_drops, self.every):
|
|
return make_tiles.KIND_FULL, bytes(current), changed, True
|
|
payload = delta_frame(self.previous, current)
|
|
if payload is None:
|
|
return make_tiles.KIND_SAME, b"", 0, False
|
|
return make_tiles.KIND_DELTA, payload, changed, False
|
|
|
|
def datagrams(self, n: int, kind: int, payload: bytes) -> list[bytes]:
|
|
"""``payload`` as one VS frame, chunked and wrapped for the transport."""
|
|
frame = pack_header(kind, n & 0xFFFF, len(payload), 0, 0,
|
|
payload=payload) + payload
|
|
chunks = [frame[i:i + self.chunk]
|
|
for i in range(0, len(frame), self.chunk)]
|
|
return [ethernet_frame(chunk, src=self.src, dst=self.dst,
|
|
pad=(i == len(chunks) - 1))
|
|
for i, chunk in enumerate(chunks)]
|
|
|
|
def send_frame(self, n: int, kind: int, payload: bytes):
|
|
for data in self.datagrams(n, kind, payload):
|
|
self.transport(data)
|
|
self.sent_datagrams += 1
|
|
self.sent_bytes += len(data)
|
|
self.sent_frames += 1
|
|
|
|
def send_dictionary(self):
|
|
for data in self.dictionary_datagrams():
|
|
self.transport(data)
|
|
self.sent_datagrams += 1
|
|
self.sent_bytes += len(data)
|
|
self.dictionary_sent += 1
|
|
|
|
# -- status --------------------------------------------------------
|
|
def poll(self):
|
|
if self.receive is None:
|
|
return
|
|
for data in (self.receive() or [])[:64]:
|
|
st = decode_status_frame(data)
|
|
if st is None:
|
|
continue
|
|
if not st["crc_ok"]:
|
|
self.bad_status += 1
|
|
self.log("status: header CRC bad, ignored (frames_rx=%d)"
|
|
% st["frames_rx"])
|
|
continue
|
|
self.statuses.append(st)
|
|
self.drops = max(self.drops, st["drops"])
|
|
self.log("status %d: frame_no=%d frames_rx=%d crc_err=%d drops=%d "
|
|
"csr0=0x%04X heap_free_kb=%d"
|
|
% (len(self.statuses), st["frame_no"], st["frames_rx"],
|
|
st["crc_err"], st["drops"], st["csr0"],
|
|
st["heap_free_kb"]))
|
|
|
|
# -- the run -------------------------------------------------------
|
|
def run(self, judge=True) -> int:
|
|
self.t0 = self.clock()
|
|
self.send_dictionary()
|
|
if self.report_every:
|
|
self.say(CSV_HEADER_TILES)
|
|
n = 0
|
|
try:
|
|
while self.frames is None or n < self.frames:
|
|
self.wait_for(n)
|
|
# The status is read *before* the decision: a drop reported
|
|
# now is a drop this frame can repair.
|
|
self.poll()
|
|
kind, payload, changed, keyframe = self.frame_plan(n)
|
|
self.seen_drops = self.drops
|
|
if kind == make_tiles.KIND_SAME:
|
|
self.same_frames += 1
|
|
else:
|
|
self.send_frame(n, kind, payload)
|
|
if kind == make_tiles.KIND_FULL:
|
|
self.full_frames += 1
|
|
self.keyframes.append(n)
|
|
else:
|
|
self.delta_frames += 1
|
|
self.previous = self.cache.indices[n % len(self.cache.indices)]
|
|
self.frames_done += 1
|
|
n += 1
|
|
if self.report_every and n % self.report_every == 0:
|
|
# The row names the frame it describes (0-based, the same
|
|
# number that went in the wire header), not the count.
|
|
self.report(n - 1, kind, payload, changed, keyframe)
|
|
except KeyboardInterrupt:
|
|
self.interrupted = True
|
|
self.log("interrupted after %d frames" % n)
|
|
if judge:
|
|
self.drain()
|
|
return self.verdict()
|
|
return 0
|
|
|
|
def wait_for(self, n: int):
|
|
"""Sleep until ``t0 + n/fps``; the first frame does not sleep."""
|
|
if self.fps <= 0:
|
|
return
|
|
delta = self.t0 + n / self.fps - self.clock()
|
|
if delta > 0:
|
|
self.sleep(delta)
|
|
|
|
def report(self, n: int, kind: int, payload: bytes, changed: int,
|
|
keyframe: bool):
|
|
elapsed = self.clock() - self.t0
|
|
rate = (n + 1) / elapsed if elapsed > 0 else 0.0
|
|
names = {make_tiles.KIND_FULL: "full", make_tiles.KIND_DELTA: "delta",
|
|
make_tiles.KIND_SAME: "same"}
|
|
self.say("%d,%.3f,%.2f,%d,%s,%d,%d,%d"
|
|
% (n, elapsed, rate, self.sent_bytes, names[kind],
|
|
len(payload), changed, 1 if keyframe else 0))
|
|
|
|
def drain(self, limit=None):
|
|
if self.receive is None or self.status_wait <= 0:
|
|
return
|
|
deadline = self.clock() + (self.status_wait if limit is None else limit)
|
|
while self.clock() < deadline:
|
|
self.poll()
|
|
if (self.statuses and self.frames is not None
|
|
and self.statuses[-1]["frames_rx"] >= self.sent_frames):
|
|
return
|
|
self.sleep(0.02)
|
|
|
|
def verdict(self) -> int:
|
|
elapsed = self.clock() - self.t0
|
|
self.log("sent %d frame(s) in %d datagrams, %d bytes, %.1f fps "
|
|
"(%d full, %d delta, %d unchanged, %d dictionary datagrams)"
|
|
% (self.sent_frames, self.sent_datagrams, self.sent_bytes,
|
|
(self.sent_frames / elapsed) if elapsed > 0 else 0.0,
|
|
self.full_frames, self.delta_frames, self.same_frames,
|
|
self.dictionary_sent))
|
|
if self.frames is not None and not self.interrupted \
|
|
and self.frames_done < self.frames:
|
|
self.log("FAIL: stopped after %d of %d frame(s)"
|
|
% (self.frames_done, self.frames))
|
|
return 1
|
|
if not self.statuses:
|
|
self.log("FAIL: no status frame received (the terminal said "
|
|
"nothing, so drops and crc_err are unknown)")
|
|
return 1
|
|
last = self.statuses[-1]
|
|
if self.bad_status:
|
|
self.log("FAIL: %d status frame(s) had a bad header CRC"
|
|
% self.bad_status)
|
|
return 1
|
|
if last["drops"] or last["crc_err"]:
|
|
self.log("FAIL: terminal reports drops=%d crc_err=%d"
|
|
% (last["drops"], last["crc_err"]))
|
|
return 1
|
|
lag = (self.sent_frames - last["frames_rx"]
|
|
if self.frames is not None else 0)
|
|
self.log("PASS: %d status frames, last frames_rx=%d "
|
|
"(drops=0 crc_err=0), %d frame(s) sent since that report"
|
|
% (len(self.statuses), last["frames_rx"], max(lag, 0)))
|
|
return 0
|
|
|
|
|
|
# --- transports -----------------------------------------------------------
|
|
|
|
def parse_mac(text: str) -> bytes:
|
|
parts = text.split(":")
|
|
if len(parts) != 6:
|
|
raise argparse.ArgumentTypeError("expected aa:bb:cc:dd:ee:ff, got %r"
|
|
% text)
|
|
try:
|
|
return bytes(int(p, 16) for p in parts)
|
|
except ValueError:
|
|
raise argparse.ArgumentTypeError("not a MAC address: %r" % text)
|
|
|
|
|
|
def mac_of(iface: str) -> bytes:
|
|
with open("/sys/class/net/%s/address" % iface) as f:
|
|
return parse_mac(f.read().strip())
|
|
|
|
|
|
class RawEthernet:
|
|
"""AF_PACKET transport: send on ``iface`` and receive the stream's type.
|
|
|
|
Two sockets, because they answer two questions: the TX socket sends a frame
|
|
the caller built (header included), the RX socket only queues frames whose
|
|
ethertype is the stream's, so the status frames arrive without the host
|
|
having to sift the interface's whole traffic. Both need CAP_NET_RAW.
|
|
"""
|
|
|
|
def __init__(self, iface: str):
|
|
self.iface = iface
|
|
self.tx = socket.socket(socket.AF_PACKET, socket.SOCK_RAW)
|
|
self.tx.bind((iface, 0))
|
|
self.rx = socket.socket(socket.AF_PACKET, socket.SOCK_RAW,
|
|
socket.htons(ETHERTYPE))
|
|
self.rx.bind((iface, 0))
|
|
self.rx.setblocking(False)
|
|
self.src = mac_of(iface)
|
|
|
|
def send(self, data: bytes):
|
|
self.tx.send(data)
|
|
|
|
def recv(self):
|
|
out = []
|
|
while True:
|
|
try:
|
|
out.append(self.rx.recv(2048))
|
|
except BlockingIOError:
|
|
return out
|
|
if len(out) >= 64:
|
|
return out
|
|
|
|
def close(self):
|
|
self.tx.close()
|
|
self.rx.close()
|
|
|
|
|
|
class CountingTransport:
|
|
"""``--dry-run``: count what the schedule would send, touch no hardware."""
|
|
|
|
def __init__(self):
|
|
self.datagrams = 0
|
|
self.bytes = 0
|
|
|
|
def __call__(self, data: bytes):
|
|
self.datagrams += 1
|
|
self.bytes += len(data)
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--iface", help="interface to send on (needs CAP_NET_RAW)")
|
|
ap.add_argument("--rows", type=int, default=128,
|
|
help="unique source rows a frame carries (default 128; "
|
|
"128x8 fills the screen, ruling R3)")
|
|
ap.add_argument("--stretch", type=int, default=8,
|
|
help="screen rows each source row paints (default 8)")
|
|
ap.add_argument("--fps", type=float, default=30.0,
|
|
help="frames per second; 0 = as fast as possible")
|
|
ap.add_argument("--frames", type=int, default=0,
|
|
help="stop after N frames (0 = until interrupted)")
|
|
ap.add_argument("--pattern", default="bars", choices=sorted(PATTERNS),
|
|
help="test picture (default bars)")
|
|
ap.add_argument("--chunk", type=int, default=CHUNK,
|
|
help="bytes of frame per datagram (default %d)" % CHUNK)
|
|
ap.add_argument("--dst", type=parse_mac, default=BROADCAST,
|
|
help="destination MAC (default broadcast: the terminal's "
|
|
"LANCE accepts broadcast without a filter entry)")
|
|
ap.add_argument("--src", type=parse_mac, default=None,
|
|
help="source MAC (default: the interface's own)")
|
|
ap.add_argument("--report-every", type=int, default=None,
|
|
help="CSV line every N frames (default %d, or 1 with "
|
|
"--tiles: the plan asks for one row per frame)"
|
|
% REPORT_EVERY)
|
|
ap.add_argument("--status-wait", type=float, default=1.0,
|
|
help="seconds to keep listening for a status after the "
|
|
"last frame (default 1.0)")
|
|
ap.add_argument("--dry-run", action="store_true",
|
|
help="run the schedule against a counter, no socket")
|
|
ap.add_argument("--tiles", action="store_true",
|
|
help="send the cached tile stream (dictionary + full/delta "
|
|
"frames) instead of strip frames")
|
|
ap.add_argument("--tiles-dir", default=make_tiles.DEFAULT_CACHE,
|
|
help="the cache make_tiles.py wrote (default %(default)s)")
|
|
args = ap.parse_args(argv)
|
|
|
|
cache = None
|
|
if args.tiles:
|
|
try:
|
|
cache = make_tiles.read_cache(args.tiles_dir)
|
|
except ValueError as exc:
|
|
print("vidsend.py: %s" % exc, file=sys.stderr)
|
|
print(" build one first: python3 harness/udpstream/make_tiles.py "
|
|
"--out %s" % args.tiles_dir, file=sys.stderr)
|
|
return 2
|
|
# Tile mode has no strip geometry: the screen is the whole 40x32 grid.
|
|
report_every = args.report_every if args.report_every is not None else 1
|
|
else:
|
|
# The geometry is vidsrc's, not this script's: `stretch` must divide
|
|
# the 1024-row screen or the encoder refuses the frame, and --rows 80
|
|
# --stretch 12 (the plan's own example) is such a pair -- 80*12 = 960
|
|
# rows, the other 64 scanlines unpainted (ruling R3). Report that as
|
|
# a usage error instead of a traceback.
|
|
try:
|
|
StripSource(args.rows, args.stretch)
|
|
except ValueError as e:
|
|
print("vidsend.py: bad geometry: %s\n"
|
|
" for a full screen rows*stretch must be 1024 (128x8 is the "
|
|
"ruled default, R3) and rows may not exceed %d, the "
|
|
"terminal's ring ceiling." % (e, MAX_ROWS), file=sys.stderr)
|
|
return 2
|
|
report_every = (args.report_every if args.report_every is not None
|
|
else REPORT_EVERY)
|
|
|
|
# A frame split across more datagrams than the terminal's RX ring holds can
|
|
# never be received: the ring runs out of descriptors and the payload drops
|
|
# the partial frame. This is worst in tile mode (a delta naming every tile
|
|
# is 3,858 bytes) and for a wide strip frame at a small --chunk; reject it
|
|
# here, where the limit can be named, rather than on the wire.
|
|
if cache is not None:
|
|
frame_bytes = MAX_TILE_FRAME
|
|
what = "a full-screen delta"
|
|
else:
|
|
frame_bytes = HEADER_SIZE + args.rows * ROW_BYTES
|
|
what = "a %d-row frame" % args.rows
|
|
need = min_chunk(frame_bytes)
|
|
if args.chunk < need:
|
|
print("vidsend.py: --chunk %d would split %s (%d bytes) across %d "
|
|
"datagrams; the terminal's RX ring holds %d, so --chunk must be "
|
|
"at least %d"
|
|
% (args.chunk, what, frame_bytes,
|
|
(frame_bytes + args.chunk - 1) // args.chunk,
|
|
RING_DATAGRAMS, need), file=sys.stderr)
|
|
return 2
|
|
|
|
def build_sender(transport, receive, src):
|
|
if cache is not None:
|
|
return TileSender(cache, transport, src=src, receive=receive,
|
|
fps=args.fps, frames=args.frames or None,
|
|
chunk=args.chunk, dst=args.dst,
|
|
report_every=report_every,
|
|
status_wait=args.status_wait)
|
|
return Sender(transport, src=src, receive=receive, rows=args.rows,
|
|
stretch=args.stretch, fps=args.fps,
|
|
frames=args.frames or None, pattern=args.pattern,
|
|
chunk=args.chunk, dst=args.dst,
|
|
report_every=report_every,
|
|
status_wait=args.status_wait)
|
|
|
|
if args.dry_run:
|
|
transport = CountingTransport()
|
|
sender = build_sender(transport, None, b"\x02\x00\x00\x00\x00\x01")
|
|
sender.status_wait = 0.0
|
|
sender.run(judge=False)
|
|
# stdout holds the CSV table only; the verdict of a dry run is prose.
|
|
if cache is not None:
|
|
print("RESULT DRY-RUN: %d frames (%d full, %d delta, %d unchanged), "
|
|
"%d dictionary datagrams, %d datagrams, %d bytes counted; "
|
|
"nothing left the host and no status was received"
|
|
% (sender.sent_frames, sender.full_frames,
|
|
sender.delta_frames, sender.same_frames,
|
|
sender.dictionary_sent, sender.sent_datagrams,
|
|
sender.sent_bytes), file=sys.stderr)
|
|
else:
|
|
print("RESULT DRY-RUN: %d frames, %d datagrams, %d bytes counted; "
|
|
"nothing left the host and no status was received"
|
|
% (sender.sent_frames, sender.sent_datagrams,
|
|
sender.sent_bytes), file=sys.stderr)
|
|
return 0
|
|
|
|
if not args.iface:
|
|
ap.error("--iface is required unless --dry-run is given")
|
|
try:
|
|
net = RawEthernet(args.iface)
|
|
except PermissionError:
|
|
print("vidsend.py: %s needs CAP_NET_RAW (run as root, or use "
|
|
"--dry-run)" % args.iface, file=sys.stderr)
|
|
return 2
|
|
except OSError as e:
|
|
print("vidsend.py: cannot open %s: %s" % (args.iface, e), file=sys.stderr)
|
|
return 2
|
|
|
|
src = args.src if args.src is not None else net.src
|
|
sender = build_sender(net.send, net.recv, src)
|
|
try:
|
|
return sender.run()
|
|
finally:
|
|
net.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|