Skip to content
Open
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
33 changes: 28 additions & 5 deletions src/compas_xr/mqtt/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,40 @@ def __init__(
time_stamp: str = None,
):
super(Header, self).__init__()
self["sequence_id"] = sequence_id or self._ensure_sequence_id()
self["response_id"] = response_id or self._ensure_response_id(increment_response_ID)
self["device_id"] = device_id or self._get_device_id()
self["time_stamp"] = time_stamp or self._get_time_stamp()
# ``is None`` rather than falsiness: sequence_id and response_id are counters that
# legitimately start at 0, and ``0 or default`` would silently discard them.
self["sequence_id"] = sequence_id if sequence_id is not None else self._ensure_sequence_id()
self["response_id"] = (
response_id if response_id is not None else self._ensure_response_id(increment_response_ID)
)
self["device_id"] = device_id if device_id is not None else self._get_device_id()
self["time_stamp"] = time_stamp if time_stamp is not None else self._get_time_stamp()

@classmethod
def parse(cls, value):
"""Parse the header information
from the input value
"""
instance = cls(value["sequence_id"], value["response_id"], value["device_id"], value["time_stamp"])
# ``parse`` is called with the decoded JSON dict off the wire, but also with a
# Header instance when a message is re-parsed locally. Unwrap to the plain mapping.
value = getattr(value, "data", value)

missing = [
key
for key in ("sequence_id", "response_id", "device_id", "time_stamp")
if value.get(key) is None
]
if missing:
raise ValueError("Information for Header parsing missing: {}.".format(", ".join(missing)))

# Keyword arguments on purpose: the first positional parameter is
# ``increment_response_ID``, so passing these positionally shifts every field.
instance = cls(
sequence_id=value["sequence_id"],
response_id=value["response_id"],
device_id=value["device_id"],
time_stamp=value["time_stamp"],
)
instance._update_sequence_counter_from_message(value["sequence_id"])
instance._update_response_id_from_message(value["response_id"])
return instance
Expand Down
30 changes: 16 additions & 14 deletions src/compas_xr/project/project_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,23 @@ def create_project_data_from_compas(

"""
qr_model = ModelExtensions().create_qr_model(qr_frames_list)

# The envelope is defined in terms of a plain compas_model Model: every consumer,
# including the C# runtime, reads "model" and "elements". Keep that shape for every
# model type so a TimberModel never produces a different contract.
data = {
"QRFrames": qr_model.__data__,
"model": model.__data__,
"elements": {str(element.guid): element for element in model.elements()},
"building_plan": building_plan,
}

# compas_timber exposes beams and joints as named views over those same elements.
# They are additive extras for timber-aware consumers, never a replacement.
if isinstance(model, TimberModel):
data = {
"QRFrames": qr_model.__data__,
"model": model.__data__,
"beams": {str(beam.guid): beam for beam in model.beams},
"joints": {str(joint.guid): joint for joint in model.joints},
"building_plan": building_plan,
}
else:
data = {
"QRFrames": qr_model.__data__,
"model": model.__data__,
"elements": {str(element.guid): element for element in model.elements()},
"building_plan": building_plan,
}
data["beams"] = {str(beam.guid): beam for beam in model.beams}
data["joints"] = {str(joint.guid): joint for joint in model.joints}

return data

def upload_data_to_project(self, data: Any, project_name: str, data_name: str) -> None:
Expand Down
63 changes: 63 additions & 0 deletions tests/compas_xr/mqtt/test_messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import pytest

from compas_xr.mqtt.messages import GetTrajectoryRequest
from compas_xr.mqtt.messages import Header


def test_header_parse_preserves_every_field():
"""Header.parse used to pass values positionally into a signature whose first
parameter is ``increment_response_ID``, shifting every field by one."""
wire = {
"sequence_id": 7,
"response_id": 3,
"device_id": "device-abc",
"time_stamp": "2026-09-08T10:00:00",
}

header = Header.parse(wire)

assert header["sequence_id"] == 7
assert header["response_id"] == 3
assert header["device_id"] == "device-abc"
assert header["time_stamp"] == "2026-09-08T10:00:00"


def test_header_parse_keeps_zero_valued_counters():
"""sequence_id and response_id are counters that legitimately start at 0, which a
falsiness check would discard and regenerate."""
wire = {
"sequence_id": 0,
"response_id": 0,
"device_id": "device-abc",
"time_stamp": "2026-09-08T10:00:00",
}

header = Header.parse(wire)

assert header["sequence_id"] == 0
assert header["response_id"] == 0


@pytest.mark.parametrize("missing", ["sequence_id", "response_id", "device_id", "time_stamp"])
def test_header_parse_rejects_incomplete_headers(missing):
wire = {
"sequence_id": 1,
"response_id": 2,
"device_id": "device-abc",
"time_stamp": "2026-09-08T10:00:00",
}
del wire[missing]

with pytest.raises(ValueError, match=missing):
Header.parse(wire)


def test_message_roundtrip_preserves_header():
request = GetTrajectoryRequest(element_id="elem-7", robot_name="ur10e")

parsed = GetTrajectoryRequest.parse(request.data)

assert parsed["element_id"] == "elem-7"
assert parsed["robot_name"] == "ur10e"
for key in ("sequence_id", "response_id", "device_id", "time_stamp"):
assert parsed["header"][key] == request["header"][key]
26 changes: 24 additions & 2 deletions tests/compas_xr/project/test_model_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
from compas_xr.project import ProjectManager


def test_compas_pb_1_1_is_installed():
assert version("compas_pb").startswith("1.1.")
def test_compas_pb_1_x_is_installed():
# requirements.txt allows >=1.1,<2, and the C# runtime targets 1.2. Pinning the test
# to 1.1 specifically would fail on a legitimate, wire-compatible upgrade.
assert version("compas_pb").startswith("1.")


def test_qr_model_roundtrips_with_compas_pb():
Expand Down Expand Up @@ -81,3 +83,23 @@ def test_project_data_uses_model_and_elements_keys():

assert set(data) == {"QRFrames", "model", "elements", "building_plan"}
assert set(data["elements"]) == {str(element.guid) for element in model.elements()}


def test_project_data_keeps_elements_for_timber_models():
"""A TimberModel used to emit beams/joints *instead of* elements, so the C# runtime,
which reads "model" and "elements", silently saw a model with no elements."""
from compas_timber.elements import Beam
from compas_timber.model import TimberModel

model = TimberModel()
model.add_element(Beam(Frame.worldXY(), length=1.0, width=0.1, height=0.2))
plan = BuildingPlanExtensions().create_buildingplan_from_model_sequence(model, data_type=0)
manager = ProjectManager.__new__(ProjectManager)

data = manager.create_project_data_from_compas(model, plan, [Frame.worldXY()])

assert "elements" in data
assert set(data["elements"]) == {str(element.guid) for element in model.elements()}
# timber-specific views stay available, but only as extras
assert "beams" in data
assert "joints" in data
Loading