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="application-private-key") 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 "application-private-key" not in repr(plan) assert "application-private-key" 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", )