mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
de40ad2ff1
Vendor only the comma payload jetlink commits to (jetlink/, LICENSE, pyproject.toml, README.md) with an include list, so the exclude list goes and upstream's README stays as published; StarPilot's notes live in JETLINK.md. The root helper now ships inside the package. The adapter moves to API 3 in the next commit; until then Jetlink stays off. d24328d stands in for the jetlink release tag commit: re-run tools/vendor/sync.py jetlink_repo <tag commit> --apply before merging and update the revision in CREDITS.md and JETLINK.md with it.
211 lines
9.2 KiB
Python
211 lines
9.2 KiB
Python
"""
|
|
Copyright (c) 2026-, Zeph Leggett.
|
|
|
|
This file is part of jetlink and is licensed under the MIT License.
|
|
See the LICENSE file in the root directory for more details.
|
|
|
|
Wire protocol.
|
|
|
|
Every message is a 32-byte header and an opaque payload. 32 keeps float32
|
|
payloads 8-byte aligned, so both ends can view the receive buffer without
|
|
copying.
|
|
|
|
INFER is a fixed struct plus two raw arrays sized at handshake, not json: one
|
|
vectored write out, one read into a preallocated buffer back.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import struct
|
|
from enum import IntEnum
|
|
|
|
MAGIC = 0x4B4E4C4A # b'JLNK'
|
|
# The comma package and the server are updated together, so each speaks exactly
|
|
# one version, and a header of any other is a broken stream. 3 keeps the hidden
|
|
# state on the server: INFER_REQ has no prev_feat and INFER_RESP no
|
|
# hidden_state slice, which takes the big models' reply from 72 KB (five 16 KB
|
|
# reads on the comma) to 8 KB (one). Tensors are unchanged, so existing plans
|
|
# still load.
|
|
VERSION = 3
|
|
|
|
# A bulk transfer ends on a short packet, so a message that is an exact multiple
|
|
# of the packet size never terminates the peer's read and arrives a frame late;
|
|
# the sender appends a pad byte and sets Flag.PADDED. 512 is a high-speed packet
|
|
# and divides a SuperSpeed one, so every message ends short on a USB 2 link too:
|
|
# an Android phone, or a cable that fell back to high speed. A reader takes the
|
|
# pad byte whenever the flag says so, at any length.
|
|
PACKET_MULTIPLE = 512
|
|
|
|
# The gadget pads every message to a burst so it never ends on a short packet:
|
|
# dwc3 flushed its TX FIFO past one about once in 400 frames, and the host read
|
|
# those bytes as the next header. Host to device keeps the PADDED byte instead.
|
|
GADGET_TX_ALIGN = 16 * 1024
|
|
|
|
# The comma's gadget as a USB host finds it: these IDs (pid.codes' test
|
|
# allocation; get a real PID before distributing this), which
|
|
# jetlink/comma/jetlink-root.sh presents, then the link's interface by its class
|
|
# triple (transport/ffs.py), because the comma set to iOS presents a composite
|
|
# gadget and a reordered config could move the number. Endpoint addresses are
|
|
# read from the descriptors, never assumed: FunctionFS renumbers them at bind.
|
|
USB_VID = 0x1209
|
|
USB_PID = 0x0001
|
|
USB_VENDOR_CLASS = (0xFF, 0xFF, 0xFF)
|
|
USB_MAX_PACKET = 1024 # SuperSpeed bulk
|
|
|
|
# magic, version, msg_type, seq, flags, length, reserved, 4 pad
|
|
HEADER_FMT = '<IHHIIIQ4x'
|
|
HEADER_SIZE = struct.calcsize(HEADER_FMT)
|
|
assert HEADER_SIZE == 32
|
|
|
|
_header = struct.Struct(HEADER_FMT)
|
|
|
|
|
|
class Msg(IntEnum):
|
|
HELLO_REQ = 1 # {} -> server describes itself
|
|
HELLO_RESP = 2 # json: server caps, loaded engine, shapes
|
|
ENGINE_REQ = 3 # json: {sha256, nbytes, frame_skip} -> make this model ready
|
|
ENGINE_RESP = 4 # json: {state: ready|need_upload|building|failed, spec when ready, ...}
|
|
UPLOAD_CHUNK = 5 # u64 offset + bytes
|
|
UPLOAD_DONE = 6 # json: {sha256}
|
|
PROGRESS = 7 # json: {stage, frac, msg} - unsolicited, server -> client
|
|
INFER_REQ = 8 # InferHeader + warped(u8) + packed(f32)
|
|
INFER_RESP = 9 # InferRespHeader + outputs(f32) but hidden_state, which stays on the server
|
|
# 10, 11 were RESET_REQ/RESP; queues are cleared with Flag.RESET_QUEUES
|
|
STATE_REQ = 12 # telemetry
|
|
STATE_RESP = 13 # json
|
|
ERROR = 14 # json: {error, detail}
|
|
PING = 15 # on a connection with no hello and no ENGINE_REQ: ERROR no_hello
|
|
PONG = 16
|
|
SHUTDOWN_REQ = 17 # json: {reason} -> power the Jetson off for good; see JetlinkClient.shutdown
|
|
SHUTDOWN_RESP = 18 # json: {ok, detail}
|
|
LEAVE = 19 # json: {reason, ...what the client measured}; client -> server, no reply.
|
|
# The client stops using the link: it handed the model back or is exiting.
|
|
# The connection may stay for a later HELLO_REQ. An older server answers
|
|
# ERROR unknown_message, which the client discards (JetlinkClient.leave)
|
|
|
|
|
|
# Why a client stops using the link, LEAVE's json 'reason' (JetlinkClient.leave);
|
|
# the server logs each in words (Session.onLeave) and anything else verbatim
|
|
LEAVE_BEHIND = 'behind' # modeld fell behind the large model
|
|
LEAVE_LOST = 'lost' # the comma lost the link, said when it can still carry it
|
|
LEAVE_STOPPED = 'stopped' # modeld stopped
|
|
LEAVE_PROVISIONED = 'provisioned' # the provisioning run finished
|
|
LEAVE_REASONS = (LEAVE_BEHIND, LEAVE_LOST, LEAVE_STOPPED, LEAVE_PROVISIONED)
|
|
|
|
|
|
class Flag(IntEnum):
|
|
RESET_QUEUES = 1 << 0 # on INFER_REQ: warm-start, clear history before this frame
|
|
WANT_STATE = 1 << 1 # on INFER_REQ: append telemetry json to the response.
|
|
# Piggybacked because at 20 Hz a separate exchange
|
|
# would race a frame.
|
|
WANT_HIDDEN = 1 << 2 # on INFER_REQ: keep hidden_state in the response, for
|
|
# a caller logging the whole output vector. 64 KB more
|
|
# on the big models; nothing else needs it.
|
|
PADDED = 1 << 7 # one pad byte follows the payload; see PACKET_MULTIPLE
|
|
|
|
|
|
# INFER_REQ: frame_id, flags. Sizes of the two arrays come from the handshake.
|
|
INFER_REQ_FMT = '<II'
|
|
INFER_REQ_SIZE = struct.calcsize(INFER_REQ_FMT)
|
|
_infer_req = struct.Struct(INFER_REQ_FMT)
|
|
|
|
# INFER_RESP: frame_id, status, server-side timings in microseconds
|
|
INFER_RESP_FMT = '<IIIII'
|
|
INFER_RESP_SIZE = struct.calcsize(INFER_RESP_FMT)
|
|
_infer_resp = struct.Struct(INFER_RESP_FMT)
|
|
|
|
|
|
# A frame over the phone's cable as UDP datagrams (TcpTransport.use_datagrams):
|
|
# the INFER_REQ's stream bytes, header and pad included, cut into equal pieces
|
|
# of at most DATAGRAM_PAYLOAD, each behind a DATAGRAM_HEADER. The comma's kernel
|
|
# cuts a datagram into IP fragments after netfilter and routing, so the stack's
|
|
# per-packet work runs once a datagram, about 7 times a frame, where TCP runs it
|
|
# for each of ~272 segments: measured 7.35 vs 4.3 ms a frame.
|
|
# The server offers it in HELLO_RESP (frame_port, frame_token) on the cable only.
|
|
# A datagram with another token is from an earlier session and is dropped.
|
|
DATAGRAM_MAGIC = 0x4D52464A # b'JFRM'
|
|
# magic, token, seq, offset, total: where this piece goes in a message of `total` bytes
|
|
DATAGRAM_HEADER_FMT = '<IIIII'
|
|
DATAGRAM_HEADER_SIZE = struct.calcsize(DATAGRAM_HEADER_FMT)
|
|
# UDP carries 65507 bytes at most; equal pieces of up to this, 7 for a big model's frame
|
|
DATAGRAM_PAYLOAD = 65000
|
|
_datagram_header = struct.Struct(DATAGRAM_HEADER_FMT)
|
|
|
|
|
|
def pack_datagram_header(token: int, seq: int, offset: int, total: int) -> bytes:
|
|
return _datagram_header.pack(DATAGRAM_MAGIC, token, seq, offset, total)
|
|
|
|
|
|
def pack_datagram_header_into(buf, token: int, seq: int, offset: int, total: int) -> None:
|
|
_datagram_header.pack_into(buf, 0, DATAGRAM_MAGIC, token, seq, offset, total)
|
|
|
|
|
|
def unpack_datagram_header(buf) -> tuple[int, int, int, int]:
|
|
"""(token, seq, offset, total); ProtocolError for anything not ours."""
|
|
magic, token, seq, offset, total = _datagram_header.unpack_from(buf)
|
|
if magic != DATAGRAM_MAGIC:
|
|
raise ProtocolError(f"bad datagram magic 0x{magic:08x}")
|
|
return token, seq, offset, total
|
|
|
|
|
|
def datagram_pieces(total: int) -> tuple[tuple[int, int], ...]:
|
|
"""(offset, size) of each datagram for a message of `total` bytes: as few as
|
|
DATAGRAM_PAYLOAD allows, equal to within a byte. The same every frame of a
|
|
model, so kept."""
|
|
return _pieces(total, DATAGRAM_PAYLOAD)
|
|
|
|
|
|
@functools.lru_cache(maxsize=8)
|
|
def _pieces(total: int, payload: int) -> tuple[tuple[int, int], ...]:
|
|
n = max(1, -(-total // payload))
|
|
base, extra = divmod(total, n)
|
|
sizes = [base + (1 if i < extra else 0) for i in range(n)]
|
|
return tuple((sum(sizes[:i]), size) for i, size in enumerate(sizes))
|
|
|
|
|
|
class ProtocolError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def pack_header(msg_type: int, seq: int, length: int, flags: int = 0, reserved: int = 0) -> bytes:
|
|
return _header.pack(MAGIC, VERSION, int(msg_type), seq, flags, length, reserved)
|
|
|
|
|
|
def unpack_header(buf) -> tuple[int, int, int, int, int, int, int]:
|
|
magic, version, msg_type, seq, flags, length, reserved = _header.unpack_from(buf)
|
|
if magic != MAGIC:
|
|
raise ProtocolError(f"bad magic 0x{magic:08x} (link desynced or not a jetlink peer)")
|
|
if version != VERSION:
|
|
raise ProtocolError(f"peer speaks protocol v{version}, we speak v{VERSION}")
|
|
return magic, version, msg_type, seq, flags, length, reserved
|
|
|
|
|
|
def pack_infer_req(frame_id: int, flags: int = 0) -> bytes:
|
|
return _infer_req.pack(frame_id, flags)
|
|
|
|
|
|
def unpack_infer_req(buf, offset: int = 0) -> tuple[int, int]:
|
|
return _infer_req.unpack_from(buf, offset)
|
|
|
|
|
|
def pack_infer_resp(frame_id: int, status: int, gpu_us: int, queue_us: int, total_us: int) -> bytes:
|
|
return _infer_resp.pack(frame_id, status, gpu_us, queue_us, total_us)
|
|
|
|
|
|
def unpack_infer_resp(buf, offset: int = 0) -> tuple[int, int, int, int, int]:
|
|
return _infer_resp.unpack_from(buf, offset)
|
|
|
|
|
|
class Status(IntEnum):
|
|
OK = 0
|
|
NOT_READY = 1 # no engine loaded
|
|
BAD_SHAPE = 2
|
|
INFER_FAILED = 3
|
|
NOT_FINITE = 4 # model produced NaN/Inf; caller must fall back
|
|
|
|
|
|
class NotFinite(ValueError):
|
|
"""A frame's output with a NaN or an inf in it, by the server's word
|
|
(Status.NOT_FINITE) or the comma's own check: the host answered, with
|
|
nothing to drive on, and the link is as good as it was. Not a LinkError."""
|