diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index b31e9dd..449872c 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -13,6 +13,7 @@ Breslav calcsize calle capsys +capsysbinary cbranch Ccsds Cdh @@ -77,6 +78,7 @@ ifelifelse IH ine ip +ipc itrunc kinda lalr diff --git a/README.md b/README.md index 406aa9b..f20488b 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/src/fpy/main.py b/src/fpy/main.py index 3e870f5..8c58748 100644 --- a/src/fpy/main.py +++ b/src/fpy/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import getpass from importlib.metadata import version from pathlib import Path import socket @@ -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) @@ -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: @@ -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: @@ -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" @@ -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 diff --git a/test/fpy/test_main.py b/test/fpy/test_main.py index d9a0d7d..6f289b1 100644 --- a/test/fpy/test_main.py +++ b/test/fpy/test_main.py @@ -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