Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Breslav
calcsize
calle
capsys
capsysbinary
cbranch
Ccsds
Cdh
Expand Down Expand Up @@ -77,6 +78,7 @@ ifelifelse
IH
ine
ip
ipc
itrunc
kinda
lalr
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,7 @@ Some useful compiler flags are:

### `fprime-fpy-cmd`

Compiles a single line of Fpy source (one command with constant arguments) and uplinks it to a running GDS, e.g. `fprime-fpy-cmd 'Ref.seqDisp.RUN_ARGS("seq.bin", NO_WAIT)' -d dict.json`. Uplinks over ZMQ by default; pass `--tcp-addr host:port` to use TCP instead. A `RUN_ARGS` line that passes sequence arguments needs `--seq-map` to locate the called sequence's `.fpy` source.
Compiles a single line of Fpy source (one command with constant arguments) and emits it, e.g. `fprime-fpy-cmd 'Ref.seqDisp.RUN_ARGS("seq.bin", NO_WAIT)' -d dict.json`. The `--emit` flag selects how: `zmq` (uplink to a running GDS over ZMQ, the default; address set by `--zmq-addr`), `tcp` (uplink to the GDS TCP server; address set by `--tcp-addr host:port`), or `stdout` (write the binary command packet to stdout).

### `fprime-fpy-asm`

Expand Down
117 changes: 82 additions & 35 deletions src/fpy/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import argparse
import getpass
from importlib.metadata import version
from pathlib import Path
import socket
Expand Down Expand Up @@ -373,30 +374,44 @@ def disassemble_main(args: list[str] = None):

FW_PACKET_COMMAND = 0

# Matches the fprime-gds default uplink URL (see fprime_gds.executables.cli).
DEFAULT_ZMQ_ADDR = f"ipc:///tmp/fprime-server-in-{getpass.getuser()}"
DEFAULT_TCP_ADDR = "127.0.0.1:50050"


def build_command_packet(cmd_opcode: int, args: bytes) -> bytes:
"""Build an F Prime command packet for ZMQ transport.
"""Build an F Prime command packet.

Format: size(4B) + descriptor_type(2B) + opcode(4B) + args
The size field covers descriptor_type + opcode + args and is stripped
by the GDS ZmqGround receiver before forwarding to the framing protocol.
Format: descriptor_type(2B) + opcode(4B) + args
The descriptor_type is a ComCfg.Apid (U16), not a U32.
"""
descriptor_type = struct.pack(">H", FW_PACKET_COMMAND)
opcode = struct.pack(">I", cmd_opcode)
payload = descriptor_type + opcode + args
size = struct.pack(">I", len(payload))
return size + payload
return descriptor_type + opcode + args


def frame_packet_for_gds(packet: bytes) -> bytes:
"""Prefix a packet with its size(4B), as the GDS transports expect.

The size field is stripped by the GDS receiver (ZmqGround or the TCP
server) before forwarding to the framing protocol.
"""
return struct.pack(">I", len(packet)) + packet


def send_command_zmq(cmd_opcode: int, args: bytes, zmq_addr: str):
"""Send a pre-serialized command to the GDS via ZMQ.

The ZMQ message format is: b"FSW" + command_packet
The ZMQ message format is: b"FSW" + size(4B) + command_packet
"""
import zmq
try:
import zmq
except ImportError as e:
raise RuntimeError(
"pyzmq is required for --emit zmq (pip install pyzmq)"
) from e

packet = build_command_packet(cmd_opcode, args)
packet = frame_packet_for_gds(build_command_packet(cmd_opcode, args))

context = zmq.Context()
sock = context.socket(zmq.PUB)
Expand All @@ -418,7 +433,7 @@ def send_command_tcp(cmd_opcode: int, args: bytes, tcp_addr: str, tcp_port: int)
2. Send command: b"A5A5 FSW " + b"ZZZZ" + size(4B) + payload
where the ZZZZ frame is what TcpServerFramerDeframer expects.
"""
packet = build_command_packet(cmd_opcode, args)
packet = frame_packet_for_gds(build_command_packet(cmd_opcode, args))

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
Expand Down Expand Up @@ -452,17 +467,29 @@ def cmd_main(args: list[str] = None):
help="The FPrime dictionary .json file",
)
_add_seq_map_argument(arg_parser)
arg_parser.add_argument(
"--emit",
choices=["zmq", "tcp", "stdout"],
default="zmq",
help=(
"How to emit the command: 'zmq' (send to the GDS uplink over ZMQ, "
"the default), 'tcp' (send to the GDS TCP server), 'stdout' (write "
"the binary command packet to stdout: packet descriptor (U16, 0 "
"for commands) + command opcode (U32) + serialized arguments, all "
"big-endian)"
),
)
arg_parser.add_argument(
"--zmq-addr",
type=str,
default="ipc:///tmp/fprime-server-in",
help="ZMQ address for the GDS uplink (default: ipc:///tmp/fprime-server-in)",
default=None,
help=f"ZMQ address of the GDS uplink, with --emit zmq (default: {DEFAULT_ZMQ_ADDR})",
)
arg_parser.add_argument(
"--tcp-addr",
type=str,
default=None,
help="TCP server address as host:port (e.g. 127.0.0.1:50050). If provided, use TCP instead of ZMQ.",
help=f"TCP server address as host:port, with --emit tcp (default: {DEFAULT_TCP_ADDR})",
)

if args is not None:
Expand All @@ -476,6 +503,34 @@ def cmd_main(args: list[str] = None):
print(e, file=sys.stderr)
sys.exit(1)

if parsed_args.zmq_addr is not None and parsed_args.emit != "zmq":
print("--zmq-addr is only valid with --emit zmq", file=sys.stderr)
sys.exit(1)
if parsed_args.tcp_addr is not None and parsed_args.emit != "tcp":
print("--tcp-addr is only valid with --emit tcp", file=sys.stderr)
sys.exit(1)

tcp_host = None
tcp_port = None
if parsed_args.emit == "tcp":
tcp_addr = parsed_args.tcp_addr or DEFAULT_TCP_ADDR
parts = tcp_addr.rsplit(":", 1)
if len(parts) != 2:
print(
f"Invalid --tcp-addr format: {tcp_addr!r} (expected host:port)",
file=sys.stderr,
)
sys.exit(1)
tcp_host = parts[0]
try:
tcp_port = int(parts[1])
except ValueError:
print(
f"Invalid port in --tcp-addr: {parts[1]!r}",
file=sys.stderr,
)
sys.exit(1)

source = parsed_args.source
if not source.endswith("\n"):
source += "\n"
Expand Down Expand Up @@ -532,33 +587,25 @@ def cmd_main(args: list[str] = None):

directive = cmd_directives[0]

if parsed_args.tcp_addr is not None:
parts = parsed_args.tcp_addr.rsplit(":", 1)
if len(parts) != 2:
print(
f"Invalid --tcp-addr format: {parsed_args.tcp_addr!r} (expected host:port)",
file=sys.stderr,
)
sys.exit(1)
tcp_host = parts[0]
try:
tcp_port = int(parts[1])
except ValueError:
print(
f"Invalid port in --tcp-addr: {parts[1]!r}",
file=sys.stderr,
)
sys.exit(1)
print(f"Sending {source.strip()} via TCP {parsed_args.tcp_addr}")
if parsed_args.emit == "stdout":
sys.stdout.buffer.write(
build_command_packet(directive.cmd_opcode, directive.args)
)
sys.stdout.buffer.flush()
elif parsed_args.emit == "tcp":
print(f"Sending {source.strip()} via TCP {tcp_host}:{tcp_port}")
try:
send_command_tcp(directive.cmd_opcode, directive.args, tcp_host, tcp_port)
except Exception as e:
print(f"Failed to send command: {e}", file=sys.stderr)
sys.exit(1)
else:
print(f"Sending {source.strip()} via {parsed_args.zmq_addr}")
elif parsed_args.emit == "zmq":
zmq_addr = parsed_args.zmq_addr or DEFAULT_ZMQ_ADDR
print(f"Sending {source.strip()} via {zmq_addr}")
try:
send_command_zmq(directive.cmd_opcode, directive.args, parsed_args.zmq_addr)
send_command_zmq(directive.cmd_opcode, directive.args, zmq_addr)
except Exception as e:
print(f"Failed to send command: {e}", file=sys.stderr)
sys.exit(1)
else:
assert False, parsed_args.emit
151 changes: 146 additions & 5 deletions test/fpy/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,17 +605,158 @@ def test_cmd_main_zmq_addr(monkeypatch, capsys):


def test_build_command_packet():
"""Command packet has correct wire format: size(4B) + descriptor(2B) + opcode(4B) + args."""
"""Command packet has correct wire format: descriptor(2B) + opcode(4B) + args."""
import struct

packet = fpy_main.build_command_packet(0x10006001, b"\x01\x02\x03")

# size = 2 (descriptor) + 4 (opcode) + 3 (args) = 9
expected_size = struct.pack(">I", 9)
expected_descriptor = struct.pack(">H", 0) # FW_PACKET_COMMAND = 0
expected_opcode = struct.pack(">I", 0x10006001)
expected_args = b"\x01\x02\x03"

assert (
packet == expected_size + expected_descriptor + expected_opcode + expected_args
assert packet == expected_descriptor + expected_opcode + expected_args


def test_frame_packet_for_gds():
"""The GDS transport frame prefixes the packet with its size(4B)."""
import struct

framed = fpy_main.frame_packet_for_gds(b"\x01\x02\x03")

assert framed == struct.pack(">I", 3) + b"\x01\x02\x03"


def _patch_compile_chain(monkeypatch, directive):
"""Stub cmd_main's compile chain to yield the given directive."""
monkeypatch.setattr(fpy_main, "text_to_ast", lambda text: "AST")
monkeypatch.setattr(
fpy_main,
"get_base_compile_state",
lambda dictionary, seq_maps=None, **kwargs: "STATE",
)
monkeypatch.setattr(fpy_main, "analyze_ast", lambda body, state: state)
monkeypatch.setattr(
fpy_main, "analysis_to_fpybc_directives", lambda state: ([directive], [])
)


def test_cmd_main_emit_stdout(monkeypatch, capsysbinary):
"""--emit stdout writes the binary command packet to stdout, nothing else."""
directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"\xab\xcd")
_patch_compile_chain(monkeypatch, directive)

fpy_main.cmd_main(
[
'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)',
"-d",
"dict.json",
"--emit",
"stdout",
]
)

assert capsysbinary.readouterr().out == fpy_main.build_command_packet(
0x10006001, b"\xab\xcd"
)


def test_cmd_main_emit_tcp(monkeypatch, capsys):
"""--emit tcp sends via the TCP server, defaulting the address."""
directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"\xab")
_patch_compile_chain(monkeypatch, directive)

sent = {}

def fake_send(cmd_opcode, args, host, port):
sent.update(cmd_opcode=cmd_opcode, args=args, host=host, port=port)

monkeypatch.setattr(fpy_main, "send_command_tcp", fake_send)

fpy_main.cmd_main(
[
'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)',
"-d",
"dict.json",
"--emit",
"tcp",
]
)

assert sent == {
"cmd_opcode": 0x10006001,
"args": b"\xab",
"host": "127.0.0.1",
"port": 50050,
}
assert "Sending" in capsys.readouterr().out


def test_cmd_main_emit_tcp_explicit_addr(monkeypatch, capsys):
"""--tcp-addr overrides the default TCP address."""
directive = ConstCmdDirective(cmd_opcode=0x10006001, args=b"")
_patch_compile_chain(monkeypatch, directive)

sent = {}
monkeypatch.setattr(
fpy_main,
"send_command_tcp",
lambda o, a, host, port: sent.update(host=host, port=port),
)

fpy_main.cmd_main(
[
'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)',
"-d",
"dict.json",
"--emit",
"tcp",
"--tcp-addr",
"192.168.1.2:60000",
]
)

assert sent == {"host": "192.168.1.2", "port": 60000}


@pytest.mark.parametrize(
"extra_args",
[
["--tcp-addr", "127.0.0.1:50050"],
["--emit", "stdout", "--tcp-addr", "127.0.0.1:50050"],
["--emit", "tcp", "--zmq-addr", "ipc:///tmp/x"],
["--emit", "stdout", "--zmq-addr", "ipc:///tmp/x"],
],
)
def test_cmd_main_addr_flag_requires_matching_emit(monkeypatch, capsys, extra_args):
"""An address flag with a mismatched --emit is an error."""
with pytest.raises(SystemExit) as exc:
fpy_main.cmd_main(
[
'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)',
"-d",
"dict.json",
*extra_args,
]
)

assert exc.value.code == 1
assert "is only valid with --emit" in capsys.readouterr().err


def test_cmd_main_invalid_tcp_addr(monkeypatch, capsys):
"""A --tcp-addr without a port is rejected before compiling."""
with pytest.raises(SystemExit) as exc:
fpy_main.cmd_main(
[
'Ref.cmdSeq0.RUN_ARGS("seq.bin", NO_WAIT)',
"-d",
"dict.json",
"--emit",
"tcp",
"--tcp-addr",
"localhost",
]
)

assert exc.value.code == 1
assert "Invalid --tcp-addr format" in capsys.readouterr().err
Loading