195 lines
6.8 KiB
Python
195 lines
6.8 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
|
ApplicationControlAuthority,
|
|
LiveDeviceControlBinding,
|
|
)
|
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
|
MODELING_STATE_BASE,
|
|
ModelingAction,
|
|
SessionState,
|
|
)
|
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
|
DEVICE_STATUS_TOPIC,
|
|
LiveModelingControlSafety,
|
|
ModelingControlSafetyError,
|
|
)
|
|
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
|
|
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
|
from k1link.viewer.metrics import BridgeMetrics
|
|
|
|
|
|
def _varint(value: int) -> bytes:
|
|
encoded = bytearray()
|
|
while value > 0x7F:
|
|
encoded.append((value & 0x7F) | 0x80)
|
|
value >>= 7
|
|
encoded.append(value)
|
|
return bytes(encoded)
|
|
|
|
|
|
def _uint(number: int, value: int) -> bytes:
|
|
return _varint(number << 3) + _varint(value)
|
|
|
|
|
|
def _bytes(number: int, value: bytes) -> bytes:
|
|
return _varint((number << 3) | 2) + _varint(len(value)) + value
|
|
|
|
|
|
def _text(number: int, value: str) -> bytes:
|
|
return _bytes(number, value.encode())
|
|
|
|
|
|
def _status_message(
|
|
state: SessionState,
|
|
*,
|
|
device_id: str = "vendor-device-001",
|
|
device_serial: str = "serial-001",
|
|
project_id: str | None = None,
|
|
source: str = "live_mqtt",
|
|
) -> StreamMessage:
|
|
header = _text(4, device_id) + _text(5, "status-message-session")
|
|
payload = _bytes(1, header) + _uint(2, MODELING_STATE_BASE + state) + _text(3, device_serial)
|
|
if project_id is not None:
|
|
payload += _text(4, project_id)
|
|
return StreamMessage(
|
|
sequence=1,
|
|
topic=DEVICE_STATUS_TOPIC,
|
|
payload=payload,
|
|
received_at_epoch_ns=1,
|
|
received_monotonic_ns=1,
|
|
source=source,
|
|
)
|
|
|
|
|
|
def _authority() -> ApplicationControlAuthority:
|
|
return ApplicationControlAuthority(openapi_key="11111111-2222-3333-4444-555555555555")
|
|
|
|
|
|
def _binding(**changes: object) -> LiveDeviceControlBinding:
|
|
values = {
|
|
"vendor_device_id": "vendor-device-001",
|
|
"device_serial": "serial-001",
|
|
"software_version": "V3.0.2-build.1",
|
|
"system_version": "V3.0.2",
|
|
"device_model": "LixelKity K1",
|
|
"device_type": "K1",
|
|
"is_activated": True,
|
|
}
|
|
values.update(changes)
|
|
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
|
|
|
|
|
|
def test_live_status_identity_is_observed_without_accepting_replay() -> None:
|
|
safety = LiveModelingControlSafety()
|
|
metrics = BridgeMetrics()
|
|
|
|
assert not safety.observe(
|
|
_status_message(SessionState.READY, source="k1mqtt"),
|
|
metrics,
|
|
)
|
|
assert safety.snapshot().status_reports_observed == 0
|
|
|
|
assert safety.observe(_status_message(SessionState.READY), metrics)
|
|
snapshot = safety.snapshot()
|
|
assert snapshot.status_reports_observed == 1
|
|
assert snapshot.vendor_identity_observed
|
|
assert snapshot.device_serial_observed
|
|
assert snapshot.ready_for_start_shadow
|
|
assert not snapshot.ready_for_stop_shadow
|
|
assert "vendor-device" not in repr(snapshot)
|
|
|
|
|
|
def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None:
|
|
safety = LiveModelingControlSafety()
|
|
safety.observe(_status_message(SessionState.READY), BridgeMetrics())
|
|
|
|
plan = safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
|
|
fields = {field.number: field for field in iter_fields(plan.command.payload, max_fields=16)}
|
|
|
|
assert plan.action is ModelingAction.START
|
|
assert fields[2].value == ModelingAction.START
|
|
assert fields[3].value == b"SAFE_PROJECT"
|
|
assert fields[4].value == 2
|
|
assert fields[5].value == 1
|
|
assert 6 not in fields
|
|
assert plan.qos == 2
|
|
assert plan.retain is False
|
|
assert plan.executable is False
|
|
assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed")
|
|
assert plan.retry_policy == "never-automatic"
|
|
assert "11111111-2222-3333-4444-555555555555" not in repr(plan)
|
|
assert "11111111-2222-3333-4444-555555555555" not in str(plan.as_dict())
|
|
|
|
|
|
def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None:
|
|
safety = LiveModelingControlSafety()
|
|
safety.observe(
|
|
_status_message(SessionState.SCANNING, project_id="private-project-id"),
|
|
BridgeMetrics(),
|
|
)
|
|
|
|
plan = safety.plan_stop(_authority(), _binding())
|
|
fields = list(iter_fields(plan.command.payload, max_fields=8))
|
|
assert plan.action is ModelingAction.STOP
|
|
assert [field.number for field in fields] == [1, 2]
|
|
|
|
with pytest.raises(ModelingControlSafetyError, match="does not match"):
|
|
safety.plan_stop(_authority(), _binding(device_serial="another-serial"))
|
|
|
|
|
|
def test_identity_drift_and_decode_failure_fail_closed() -> None:
|
|
safety = LiveModelingControlSafety()
|
|
metrics = BridgeMetrics()
|
|
safety.observe(_status_message(SessionState.READY), metrics)
|
|
safety.observe(
|
|
_status_message(SessionState.READY, device_id="different-vendor-device"),
|
|
metrics,
|
|
)
|
|
assert safety.snapshot().identity_conflict
|
|
with pytest.raises(ModelingControlSafetyError, match="conflicted"):
|
|
safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
|
|
|
|
safety.reset()
|
|
malformed = StreamMessage(
|
|
sequence=2,
|
|
topic=DEVICE_STATUS_TOPIC,
|
|
payload=b"\x10",
|
|
received_at_epoch_ns=2,
|
|
received_monotonic_ns=2,
|
|
source="live_mqtt",
|
|
)
|
|
assert safety.observe(malformed, metrics)
|
|
assert safety.snapshot().decode_errors == 1
|
|
with pytest.raises(ModelingControlSafetyError, match="conflicted"):
|
|
safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
|
|
|
|
|
|
def test_shadow_plan_requires_exact_safe_lifecycle_state() -> None:
|
|
safety = LiveModelingControlSafety()
|
|
safety.observe(_status_message(SessionState.SCAN_STARTING), BridgeMetrics())
|
|
with pytest.raises(ModelingControlSafetyError, match="must be ready"):
|
|
safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT")
|
|
with pytest.raises(ModelingControlSafetyError, match="must be scanning"):
|
|
safety.plan_stop(_authority(), _binding())
|
|
|
|
|
|
def test_device_info_binding_rejects_unreviewed_or_inactive_profile() -> None:
|
|
safety = LiveModelingControlSafety()
|
|
safety.observe(_status_message(SessionState.READY), BridgeMetrics())
|
|
|
|
with pytest.raises(ModelingControlSafetyError, match="does not attest"):
|
|
safety.plan_start(
|
|
_authority(),
|
|
_binding(software_version="V3.0.3"),
|
|
project_name="SAFE_PROJECT",
|
|
)
|
|
with pytest.raises(ModelingControlSafetyError, match="does not attest"):
|
|
safety.plan_start(
|
|
_authority(),
|
|
_binding(is_activated=False),
|
|
project_name="SAFE_PROJECT",
|
|
)
|