Skip to content
Closed
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
16 changes: 11 additions & 5 deletions src/fpy/codegen_fpybc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1203,20 +1203,26 @@ def emit_AstAssign(self, node: AstAssign, state: CompileState):
# Emit code to compute each dynamic offset component
# (idx * elem_size) and sum them together on the stack.
for i, (idx_expr, parent_type) in enumerate(dynamic_components):
# the bounds check's label is named after the node it is
# given; the index expression is unique per component, the
# assignment is not
dirs.extend(
self._emit_array_element_offset(node, idx_expr, parent_type, state)
self._emit_array_element_offset(
idx_expr, idx_expr, parent_type, state
)
)
if i > 0:
dirs.append(IntAddDirective())

# Add the constant part: base variable's frame offset +
# accumulated constant field offsets.
# accumulated constant field offsets. Signed, because a parameter
# sits below the frame start at a negative offset.
const_part = base_frame_offset + field_const_offset
dirs.append(PushValDirective(FpyValue(U64, const_part).serialize()))
dirs.append(PushValDirective(FpyValue(I64, const_part).serialize()))
dirs.append(IntAddDirective())

# and now convert the u64 back into the SignedStackSizeType that store expects
dirs.extend(self.convert_numeric_type(U64, SignedStackSizeType))
# and now convert the i64 back into the SignedStackSizeType that store expects
dirs.extend(self.convert_numeric_type(I64, SignedStackSizeType))

# now that the frame offset is pushed, use it to store into the frame
if use_abs:
Expand Down
3 changes: 3 additions & 0 deletions src/fpy/macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,9 @@ def generate_write_to_port_llvm(builder, args):
("timeContext", U8, FpyValue(U8, 0)),
],
lambda n, c, t: [], # placeholder - const eval handles this
# every argument must be a compile-time constant: time() is always
# evaluated at compile time and has no runtime lowering
const_arg_indices=frozenset({0, 1, 2}),
)

MACROS: dict[str, BuiltinFuncSymbol] = {
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 @@ -391,30 +392,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 @@ -436,7 +451,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 @@ -471,17 +486,29 @@ def cmd_main(args: list[str] = None):
)
_add_seq_map_argument(arg_parser)
_add_time_base_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 @@ -495,6 +522,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 @@ -552,33 +607,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
36 changes: 31 additions & 5 deletions src/fpy/semantics.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from decimal import Decimal
import decimal
import itertools
import math
from pathlib import Path
import struct
from typing import Union
Expand Down Expand Up @@ -2270,9 +2271,11 @@ def _parse_time_string(
dt = dt.replace(tzinfo=timezone.utc)
timestamp = dt.timestamp()

# Split into seconds and microseconds
# Split into seconds and microseconds. The microseconds come
# from the parsed value, not from the float timestamp, whose
# resolution is coarser than a microsecond at modern dates.
seconds = int(timestamp)
useconds = int((timestamp - seconds) * 1_000_000)
useconds = dt.microsecond

# Validate ranges for U32
if seconds < 0:
Expand Down Expand Up @@ -2356,7 +2359,10 @@ def const_convert_type(
rounded_value = CalculateConstExprValues._round_float_to_type(
coerced_value, to_type
)
if rounded_value is None:
# a value too large for the type either fails to pack or, past
# the double range, silently becomes infinite; there is no
# literal for an infinite value, so both are out of range
if rounded_value is None or not math.isfinite(rounded_value):
state.err(
f"{raw_val} is out of range for type {to_type.display_name}",
node,
Expand Down Expand Up @@ -2672,6 +2678,18 @@ def visit_AstFuncCall(self, node: AstFuncCall, state: CompileState):

state.const_expr_values[node] = expr_value

@staticmethod
def _const_equal(lhs: FpyValue, rhs: FpyValue) -> bool:
"""== of two constants. Numbers compare by value; a non-numeric operand
(struct, array, enum, time) compares by serialized bytes, which is
what the runtime compares (spec "Equality semantics"), so that folding
cannot change a comparison's answer: 0.0 and -0.0 are equal as
numbers but not as struct members."""
if lhs.type.is_numerical:
return lhs.val == rhs.val
assert lhs.type == rhs.type, (lhs.type, rhs.type)
return lhs.serialize() == rhs.serialize()

def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState):
# Check if both left-hand side (lhs) and right-hand side (rhs) are constants
lhs_value: FpyValue = state.const_expr_values.get(node.lhs)
Expand All @@ -2689,6 +2707,8 @@ def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState):
return

# Both sides are constants, evaluate the operation if the operator is supported
lhs_const = lhs_value
rhs_const = rhs_value
# get the actual pythonic value from the fpy type
lhs_value = lhs_value.val
rhs_value = rhs_value.val
Expand All @@ -2706,6 +2726,12 @@ def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState):
folded_value = lhs_value / rhs_value
elif node.op == BinaryStackOp.EXPONENT:
folded_value = lhs_value**rhs_value
if isinstance(folded_value, complex):
# float ** float returns a complex number for a negative
# base and a fractional exponent, where the Decimal path
# raises decimal.InvalidOperation
state.err("Domain error", node)
return
elif node.op == BinaryStackOp.FLOOR_DIVIDE:
# Floor toward -inf (Python `//`), matching the runtime backends.
if isinstance(lhs_value, int) and isinstance(rhs_value, int):
Expand Down Expand Up @@ -2736,9 +2762,9 @@ def visit_AstBinaryOp(self, node: AstBinaryOp, state: CompileState):
folded_value = lhs_value <= rhs_value
# Equality Checking
elif node.op == BinaryStackOp.EQUAL:
folded_value = lhs_value == rhs_value
folded_value = self._const_equal(lhs_const, rhs_const)
elif node.op == BinaryStackOp.NOT_EQUAL:
folded_value = lhs_value != rhs_value
folded_value = not self._const_equal(lhs_const, rhs_const)
else:
# missing an operation
assert False, node.op
Expand Down
1 change: 1 addition & 0 deletions src/fpy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,7 @@ class PrmDef:
"FORMAT_ERROR": 3,
"EXECUTION_ERROR": 4,
"BUSY": 5,
"CLEARED": 6,
},
rep_type=U8,
)
Expand Down
24 changes: 20 additions & 4 deletions src/fpy/wasm_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,31 @@ def _declare_host_func(
return fn


def _declare_libcalls(module: ir.Module) -> None:
"""Claim the names of the float libcalls LLVM emits for llvm.pow, llvm.log
and frem, before any script symbol is named. Script functions and
variables are emitted under their own names, uniqued against the module
(``get_unique_name``), so without these declarations a script function or
variable named ``pow`` would be the symbol the libcall binds to."""
from llvmlite import ir

f64 = ir.DoubleType()
ir.Function(module, ir.FunctionType(f64, [f64, f64]), name="pow")
ir.Function(module, ir.FunctionType(f64, [f64]), name="log")
ir.Function(module, ir.FunctionType(f64, [f64, f64]), name="fmod")


def declare_host_imports(module: ir.Module) -> None:
"""Declare the full expected host interface on *module*; emit sites look
the functions up in ``module.globals``. The float libcalls
(env.pow/fmod/log) are deliberately absent: LLVM materializes those itself
when lowering llvm.pow/llvm.log/frem, and they stay under wasm-ld's
default import module "env"."""
the functions up in ``module.globals``. Also declares the float libcalls
(env.pow/fmod/log) that LLVM materializes itself when lowering
llvm.pow/llvm.log/frem, only so that their names are taken: those stay
under wasm-ld's default import module "env" and no emit site calls
them."""
from llvmlite import ir

error_code_type = ErrorCodeType.llvm_type
_declare_libcalls(module)

# exit(code) ends the whole sequence. It never returns to wasm (the host
# unwinds the interpreter); noreturn lets LLVM drop the dead code after it.
Expand Down
5 changes: 5 additions & 0 deletions test/fpy/RefTopologyDictionary.json
Original file line number Diff line number Diff line change
Expand Up @@ -1899,6 +1899,11 @@
"name" : "BUSY",
"value" : 5,
"annotation" : "Component busy"
},
{
"name" : "CLEARED",
"value" : 6,
"annotation" : "Command tracking was cleared before the command completed"
}
],
"default" : "Fw.CmdResponse.OK",
Expand Down
Loading
Loading