feat: synchronize CH1 vehicle control and feedback
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
"""Decode authoritative CH1 vehicle feedback and match requested targets.
|
||||
|
||||
The ZLG SDK accepting a transmit request is not evidence that an ECU acted.
|
||||
This module contains the small, byte-exact subset of CAN_DEV feedback that the
|
||||
web console is allowed to present as physical state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
|
||||
|
||||
|
||||
PHYSICAL_SOURCE = "physical_can_ch1"
|
||||
FRAME_ID_PATTERN = re.compile(r"^0x[0-9A-F]{3}$", re.IGNORECASE)
|
||||
HEX_BYTE_PATTERN = re.compile(r"^[0-9A-F]{2}$")
|
||||
|
||||
|
||||
class VehicleSafetyPreflightError(RuntimeError):
|
||||
"""Raised when fresh CH1 evidence cannot prove a stationary vehicle."""
|
||||
|
||||
|
||||
def authoritative_physical_ch1_feedback_state(
|
||||
operation: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a verified decoded state patch for one real CH1 operation.
|
||||
|
||||
A transmit acknowledgement or a matching flag alone is insufficient. The
|
||||
raw feedback frame, its channel, and both decoded frame identifiers must
|
||||
describe the same complete frame before its state may reach the UI store.
|
||||
"""
|
||||
|
||||
telemetry = operation.get("telemetry")
|
||||
if not isinstance(telemetry, dict):
|
||||
return None
|
||||
feedback_frame = telemetry.get("feedback_frame")
|
||||
feedback_state = telemetry.get("feedback_state")
|
||||
if not isinstance(feedback_frame, dict) or not isinstance(
|
||||
feedback_state, dict
|
||||
):
|
||||
return None
|
||||
|
||||
raw_frame_id = str(feedback_frame.get("frame_id", ""))
|
||||
frame_id = (
|
||||
f"0x{raw_frame_id[2:].upper()}"
|
||||
if FRAME_ID_PATTERN.fullmatch(raw_frame_id)
|
||||
else raw_frame_id
|
||||
)
|
||||
payload_size = feedback_frame.get("payload_size")
|
||||
data_hex = feedback_frame.get("data_hex")
|
||||
if (
|
||||
operation.get("status") != "succeeded"
|
||||
or telemetry.get("channel") != 1
|
||||
or telemetry.get("simulated") is not False
|
||||
or telemetry.get("feedback_required") is not True
|
||||
or telemetry.get("feedback_observed") is not True
|
||||
or telemetry.get("feedback_match") is not True
|
||||
or feedback_frame.get("channel") != 1
|
||||
or not FRAME_ID_PATTERN.fullmatch(frame_id)
|
||||
or not isinstance(payload_size, int)
|
||||
or isinstance(payload_size, bool)
|
||||
or not 1 <= payload_size <= 64
|
||||
or not isinstance(data_hex, str)
|
||||
):
|
||||
return None
|
||||
|
||||
payload = data_hex.upper().split()
|
||||
if len(payload) != payload_size or not all(
|
||||
HEX_BYTE_PATTERN.fullmatch(byte) for byte in payload
|
||||
):
|
||||
return None
|
||||
timestamp = feedback_frame.get("timestamp")
|
||||
if timestamp is not None and (
|
||||
not isinstance(timestamp, (int, float))
|
||||
or isinstance(timestamp, bool)
|
||||
or not math.isfinite(float(timestamp))
|
||||
or float(timestamp) < 0
|
||||
):
|
||||
return None
|
||||
|
||||
state_payload_present = any(
|
||||
isinstance(feedback_state.get(key), dict) and bool(feedback_state.get(key))
|
||||
for key in ("domain_state", "actuator_state")
|
||||
)
|
||||
if (
|
||||
feedback_state.get("source") != PHYSICAL_SOURCE
|
||||
or feedback_state.get("channel") != 1
|
||||
or feedback_state.get("valid") is not True
|
||||
or str(feedback_state.get("frame_id", "")).lower() != frame_id.lower()
|
||||
or str(feedback_state.get("feedback_frame_id", "")).lower()
|
||||
!= frame_id.lower()
|
||||
or not state_payload_present
|
||||
):
|
||||
return None
|
||||
|
||||
verified = deepcopy(feedback_state)
|
||||
verified["frame_id"] = frame_id
|
||||
verified["feedback_frame_id"] = frame_id
|
||||
verified["verification"] = {
|
||||
"authoritative": True,
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
"feedback_required": True,
|
||||
"feedback_observed": True,
|
||||
"feedback_match": True,
|
||||
"frame_id": frame_id,
|
||||
"payload_size": payload_size,
|
||||
}
|
||||
return verified
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _bits(data: bytes, start: int, length: int) -> int:
|
||||
value = 0
|
||||
for offset in range(length):
|
||||
bit = start + offset
|
||||
if bit // 8 >= len(data):
|
||||
raise ValueError("feedback frame is shorter than the requested signal")
|
||||
if data[bit // 8] & (1 << (bit % 8)):
|
||||
value |= 1 << offset
|
||||
return value
|
||||
|
||||
|
||||
def _state(value: int, *, on: int = 1, off: int = 2) -> str:
|
||||
if value == on:
|
||||
return "on"
|
||||
if value == off:
|
||||
return "off"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def decode_feedback(frame: CanFrame) -> dict[str, Any]:
|
||||
"""Return only state fields proven by one recognized feedback frame."""
|
||||
|
||||
data = frame.data
|
||||
decoded: dict[str, Any] = {
|
||||
"source": PHYSICAL_SOURCE,
|
||||
"channel": frame.channel,
|
||||
"frame_id": f"0x{frame.arbitration_id:03X}",
|
||||
}
|
||||
|
||||
if frame.arbitration_id == 0x342 and len(data) >= 8:
|
||||
gear_raw = _bits(data, 24, 4)
|
||||
decoded["vehicle_safety"] = {
|
||||
"gear": {1: "P", 2: "R", 3: "N", 4: "D"}.get(gear_raw, "unknown")
|
||||
}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x4EB and len(data) >= 8 and data[0] == 0x07:
|
||||
speed_raw = _bits(data, 8, 12)
|
||||
decoded["vehicle_safety"] = {
|
||||
"speed_kmh": None if speed_raw == 0xFFF else round(speed_raw * 0.06875, 3)
|
||||
}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x407 and len(data) >= 8:
|
||||
names = ("front_left", "front_right", "rear_left", "rear_right")
|
||||
decoded["domain_state"] = {
|
||||
"door": {
|
||||
"locks": {
|
||||
name: {"state": {1: "unlock", 2: "lock"}.get(_bits(data, index * 2, 2), "unknown")}
|
||||
for index, name in enumerate(names)
|
||||
},
|
||||
"vehicle_lock": {
|
||||
"state": {1: "unlock", 2: "lock"}.get(_bits(data, 10, 2), "unknown")
|
||||
},
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x388 and len(data) >= 8:
|
||||
names = ("front_left", "rear_left", "front_right", "rear_right")
|
||||
positions = {
|
||||
name: {
|
||||
"position_pct": int(data[2 + index]) if data[2 + index] <= 100 else None,
|
||||
"closed": {1: True, 2: False}.get(_bits(data, 48 + index * 2, 2)),
|
||||
}
|
||||
for index, name in enumerate(names)
|
||||
}
|
||||
decoded["domain_state"] = {"window": {"positions": positions}}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x404 and len(data) >= 8:
|
||||
power = _state(_bits(data, 8, 2))
|
||||
control = {1: "manual", 2: "auto"}.get(_bits(data, 10, 2), "unknown")
|
||||
cycle = {1: "external", 2: "internal", 3: "auto"}.get(
|
||||
_bits(data, 12, 2), "unknown"
|
||||
)
|
||||
mode_raw = _bits(data, 16, 4)
|
||||
mode = {
|
||||
1: "face",
|
||||
2: "face_feet",
|
||||
3: "feet",
|
||||
4: "feet_defrost",
|
||||
5: "defrost",
|
||||
6: "face_feet_defrost",
|
||||
7: "face_defrost",
|
||||
}.get(mode_raw, "unknown")
|
||||
decoded["domain_state"] = {
|
||||
"climate": {
|
||||
"power": power,
|
||||
"ac": power,
|
||||
"control_mode": control,
|
||||
"cycle_mode": cycle,
|
||||
"fan_level": _bits(data, 20, 4),
|
||||
"zones": {
|
||||
"driver": {"temperature_c": int(data[4]), "outlet_mode": mode},
|
||||
"passenger": {"temperature_c": int(data[5]), "outlet_mode": mode},
|
||||
},
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x4FA and len(data) >= 8:
|
||||
thermal = {1: ("off", 0), 2: ("on", 1), 3: ("on", 3)}
|
||||
vent_state, vent_level = thermal.get(_bits(data, 0, 3), ("unknown", 0))
|
||||
heat_state, heat_level = thermal.get(_bits(data, 3, 3), ("unknown", 0))
|
||||
massage_mode_raw = _bits(data, 40, 4)
|
||||
massage_strength_raw = _bits(data, 44, 4)
|
||||
decoded["actuator_state"] = {
|
||||
"driver": {
|
||||
"ventilation": {"state": vent_state, "level": vent_level},
|
||||
"heating": {"state": heat_state, "level": heat_level},
|
||||
"massage": {
|
||||
"mode": {
|
||||
1: "off",
|
||||
2: "wave",
|
||||
3: "pulse",
|
||||
4: "stretch",
|
||||
5: "slack",
|
||||
6: "relax",
|
||||
}.get(massage_mode_raw, "unknown"),
|
||||
"strength": {8: "off", 9: "weak", 10: "medium", 11: "strong"}.get(
|
||||
massage_strength_raw, "unknown"
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x494 and len(data) >= 8:
|
||||
decoded["actuator_state"] = {
|
||||
"driver": {
|
||||
"position_feedback": {
|
||||
"absolute_position_available": False,
|
||||
"initialized": {
|
||||
"horizontal": bool(_bits(data, 58, 1)),
|
||||
"backrest": bool(_bits(data, 59, 1)),
|
||||
"height": bool(_bits(data, 60, 1)),
|
||||
"basin": bool(_bits(data, 61, 1)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x42E and len(data) >= 8:
|
||||
front_brightness_raw = _bits(data, 0, 4)
|
||||
rear_brightness_raw = _bits(data, 4, 4)
|
||||
front_color = _bits(data, 8, 7)
|
||||
rear_color = _bits(data, 15, 7)
|
||||
ambient_raw = _bits(data, 32, 3)
|
||||
indoor_raw = _bits(data, 37, 2)
|
||||
if ambient_raw == 1 or (
|
||||
ambient_raw == 0
|
||||
and 2 <= front_brightness_raw <= 6
|
||||
and 2 <= rear_brightness_raw <= 6
|
||||
):
|
||||
ambient_power = "on"
|
||||
elif ambient_raw == 2 or (
|
||||
ambient_raw == 0
|
||||
and front_brightness_raw == 1
|
||||
and rear_brightness_raw == 1
|
||||
):
|
||||
ambient_power = "off"
|
||||
else:
|
||||
ambient_power = "unknown"
|
||||
decoded["domain_state"] = {
|
||||
"interior_light": {"power": _state(indoor_raw, on=2, off=1)},
|
||||
"ambient_light": {
|
||||
"power": ambient_power,
|
||||
"front_brightness_level": (
|
||||
front_brightness_raw - 1 if 1 <= front_brightness_raw <= 6 else None
|
||||
),
|
||||
"rear_brightness_level": (
|
||||
rear_brightness_raw - 1 if 1 <= rear_brightness_raw <= 6 else None
|
||||
),
|
||||
"front_color_index": front_color,
|
||||
"rear_color_index": rear_color,
|
||||
},
|
||||
}
|
||||
return decoded
|
||||
|
||||
if frame.arbitration_id == 0x41A and len(data) >= 8:
|
||||
position = int(data[1]) if data[1] <= 100 else None
|
||||
decoded["domain_state"] = {
|
||||
"roof": {
|
||||
"shade_position_pct": position,
|
||||
"initialized": _bits(data, 18, 2) == 1,
|
||||
"closed": {1: True, 2: False}.get(_bits(data, 22, 2)),
|
||||
"motion_state": {
|
||||
1: "stopped",
|
||||
2: "opening",
|
||||
3: "closing",
|
||||
4: "anti_pinch",
|
||||
}.get(_bits(data, 27, 3), "unknown"),
|
||||
"thermal_protection": bool(_bits(data, 49, 1)),
|
||||
}
|
||||
}
|
||||
return decoded
|
||||
|
||||
return decoded
|
||||
|
||||
|
||||
def feedback_matches(expected: str, frame: CanFrame | None) -> bool | None:
|
||||
"""Compare one authoritative feedback frame with a compiled expectation."""
|
||||
|
||||
if frame is None or not expected:
|
||||
return None
|
||||
decoded = decode_feedback(frame)
|
||||
parts = expected.split(":")
|
||||
family = parts[0]
|
||||
|
||||
if family.startswith("climate_"):
|
||||
climate = decoded.get("domain_state", {}).get("climate")
|
||||
if not climate:
|
||||
return None
|
||||
if family == "climate_power":
|
||||
return climate["power"] == parts[1]
|
||||
if family == "climate_control":
|
||||
return climate["control_mode"] == {"1": "manual", "2": "auto"}.get(parts[1])
|
||||
if family == "climate_mode":
|
||||
expected_mode = {
|
||||
"1": "face",
|
||||
"2": "face_feet",
|
||||
"3": "feet",
|
||||
"4": "feet_defrost",
|
||||
"5": "defrost",
|
||||
"6": "face_feet_defrost",
|
||||
"7": "face_defrost",
|
||||
}.get(parts[1])
|
||||
return climate["zones"]["driver"]["outlet_mode"] == expected_mode
|
||||
if family == "climate_fan":
|
||||
return climate["fan_level"] == int(parts[1])
|
||||
if family == "climate_temperature":
|
||||
return climate["zones"].get(parts[1], {}).get("temperature_c") == int(parts[2])
|
||||
|
||||
if family == "rear_lock":
|
||||
locks = decoded.get("domain_state", {}).get("door", {}).get("locks", {})
|
||||
return bool(locks) and all(
|
||||
locks.get(target, {}).get("state") == parts[1]
|
||||
for target in ("rear_left", "rear_right")
|
||||
)
|
||||
|
||||
if family == "vehicle_lock":
|
||||
door = decoded.get("domain_state", {}).get("door", {})
|
||||
locks = door.get("locks", {})
|
||||
requested = parts[1]
|
||||
return bool(locks) and door.get("vehicle_lock", {}).get("state") == requested and all(
|
||||
locks.get(target, {}).get("state") == requested
|
||||
for target in ("front_left", "front_right", "rear_left", "rear_right")
|
||||
)
|
||||
|
||||
if family == "window_motion":
|
||||
positions = decoded.get("domain_state", {}).get("window", {}).get("positions", {})
|
||||
target, motion = parts[1], parts[2]
|
||||
targets = {
|
||||
"all": ("front_left", "rear_left", "front_right", "rear_right"),
|
||||
"front": ("front_left", "front_right"),
|
||||
"rear": ("rear_left", "rear_right"),
|
||||
}.get(target, (target,))
|
||||
if not positions or motion == "stop":
|
||||
return None
|
||||
desired_closed = motion == "close"
|
||||
return all(positions.get(item, {}).get("closed") is desired_closed for item in targets)
|
||||
|
||||
if family == "window_position":
|
||||
position = (
|
||||
decoded.get("domain_state", {})
|
||||
.get("window", {})
|
||||
.get("positions", {})
|
||||
.get(parts[1], {})
|
||||
.get("position_pct")
|
||||
)
|
||||
return position is not None and abs(position - int(parts[2])) <= 3
|
||||
|
||||
if family == "indoor_light":
|
||||
power = decoded.get("domain_state", {}).get("interior_light", {}).get("power")
|
||||
return power == parts[1]
|
||||
|
||||
if family == "ambient_power":
|
||||
power = decoded.get("domain_state", {}).get("ambient_light", {}).get("power")
|
||||
return power == parts[1]
|
||||
|
||||
if family == "ambient_color":
|
||||
ambient = decoded.get("domain_state", {}).get("ambient_light", {})
|
||||
return ambient.get("front_color_index") == int(parts[1]) and ambient.get(
|
||||
"rear_color_index"
|
||||
) == int(parts[1])
|
||||
|
||||
if family == "ambient_brightness":
|
||||
ambient = decoded.get("domain_state", {}).get("ambient_light", {})
|
||||
return ambient.get("front_brightness_level") == int(parts[1]) and ambient.get(
|
||||
"rear_brightness_level"
|
||||
) == int(parts[1])
|
||||
|
||||
if family == "shade_stopped":
|
||||
roof = decoded.get("domain_state", {}).get("roof")
|
||||
return None if not roof else roof.get("motion_state") == "stopped"
|
||||
|
||||
if family in {"shade_motion", "shade_position"}:
|
||||
position = decoded.get("domain_state", {}).get("roof", {}).get("shade_position_pct")
|
||||
if position is None:
|
||||
return False
|
||||
if family == "shade_position":
|
||||
target = int(parts[1])
|
||||
else:
|
||||
target = {"1": 100, "2": 0, "3": 50}.get(parts[1])
|
||||
if target is None:
|
||||
return None
|
||||
tolerance = 2 if target in {0, 100} else 3
|
||||
return abs(position - target) <= tolerance
|
||||
|
||||
if family == "driver_seat_thermal":
|
||||
feature, target_state = parts[1], parts[2]
|
||||
driver = decoded.get("actuator_state", {}).get("driver", {})
|
||||
actual = driver.get(feature, {})
|
||||
if target_state == "off":
|
||||
return actual.get("state") == "off"
|
||||
target_level = 1 if target_state == "low" else 3
|
||||
return actual.get("state") == "on" and actual.get("level") == target_level
|
||||
|
||||
# The compiler keeps the raw 0x4FA enum in these expectations so its
|
||||
# byte-exact contract remains directly comparable with CAN_DEV.
|
||||
if family in {"driver_seat_ventilation", "driver_seat_heating"}:
|
||||
if frame.arbitration_id != 0x4FA or len(frame.data) < 8:
|
||||
return None
|
||||
actual_raw = (
|
||||
frame.data[0] & 0x07
|
||||
if family == "driver_seat_ventilation"
|
||||
else (frame.data[0] >> 3) & 0x07
|
||||
)
|
||||
return actual_raw == int(parts[1])
|
||||
|
||||
if family == "driver_seat_massage_mode":
|
||||
actual = (
|
||||
decoded.get("actuator_state", {})
|
||||
.get("driver", {})
|
||||
.get("massage", {})
|
||||
.get("mode")
|
||||
)
|
||||
expected_mode = {
|
||||
"1": "off",
|
||||
"2": "wave",
|
||||
"3": "pulse",
|
||||
"4": "stretch",
|
||||
"5": "slack",
|
||||
"6": "relax",
|
||||
}.get(parts[1], parts[1])
|
||||
return actual == expected_mode
|
||||
|
||||
if family == "driver_seat_massage_strength":
|
||||
actual = (
|
||||
decoded.get("actuator_state", {})
|
||||
.get("driver", {})
|
||||
.get("massage", {})
|
||||
.get("strength")
|
||||
)
|
||||
expected_strength = {
|
||||
"8": "off",
|
||||
"9": "weak",
|
||||
"10": "medium",
|
||||
"11": "strong",
|
||||
}.get(parts[1], parts[1])
|
||||
return actual == expected_strength
|
||||
|
||||
if family == "driver_seat_axis":
|
||||
# 0x494 exposes initialization only. It has no motor position or
|
||||
# target-reached signal, so it cannot prove a physical axis movement.
|
||||
return None
|
||||
|
||||
if family == "driver_seat_axis_initialized":
|
||||
initialized = (
|
||||
decoded.get("actuator_state", {})
|
||||
.get("driver", {})
|
||||
.get("position_feedback", {})
|
||||
.get("initialized", {})
|
||||
)
|
||||
return initialized.get(parts[1]) is True
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def observe_vehicle_safety(
|
||||
bus: CanBus,
|
||||
*,
|
||||
timeout_ms: int = 2_500,
|
||||
required_channel: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""Passively collect fresh gear and speed frames without transmitting.
|
||||
|
||||
A newly opened channel is observed until both independent safety frames are
|
||||
received. Frames from any channel other than the physical CH1 body bus are
|
||||
ignored, so a caller cannot accidentally satisfy the gate with CH0 traffic.
|
||||
"""
|
||||
|
||||
if required_channel != 1:
|
||||
raise VehicleSafetyPreflightError("vehicle safety policy permits only CH1")
|
||||
if not 100 <= int(timeout_ms) <= 10_000:
|
||||
raise VehicleSafetyPreflightError(
|
||||
"vehicle safety observation timeout must be between 100 and 10000 ms"
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + int(timeout_ms) / 1_000
|
||||
gear: str | None = None
|
||||
speed_kmh: float | None = None
|
||||
frame_count = 0
|
||||
ignored_wrong_channel = 0
|
||||
evidence: dict[str, dict[str, Any]] = {}
|
||||
while time.monotonic() < deadline and (gear is None or speed_kmh is None):
|
||||
remaining_ms = max(1, int((deadline - time.monotonic()) * 1_000))
|
||||
frame = bus.receive(min(remaining_ms, 100), include_fd=True)
|
||||
if frame is None:
|
||||
continue
|
||||
frame_count += 1
|
||||
if frame.channel != required_channel:
|
||||
ignored_wrong_channel += 1
|
||||
continue
|
||||
decoded = decode_feedback(frame)
|
||||
safety = decoded.get("vehicle_safety")
|
||||
if not isinstance(safety, dict):
|
||||
continue
|
||||
if "gear" in safety:
|
||||
gear = str(safety["gear"])
|
||||
evidence["gear"] = {
|
||||
"frame_id": decoded["frame_id"],
|
||||
"channel": frame.channel,
|
||||
"data_hex": frame.data.hex(" ").upper(),
|
||||
}
|
||||
if "speed_kmh" in safety:
|
||||
raw_speed = safety["speed_kmh"]
|
||||
speed_kmh = (
|
||||
float(raw_speed) if isinstance(raw_speed, (int, float)) else None
|
||||
)
|
||||
evidence["speed"] = {
|
||||
"frame_id": decoded["frame_id"],
|
||||
"channel": frame.channel,
|
||||
"data_hex": frame.data.hex(" ").upper(),
|
||||
}
|
||||
|
||||
return {
|
||||
"source": PHYSICAL_SOURCE,
|
||||
"channel": required_channel,
|
||||
"read_only": True,
|
||||
"captured_at": _now(),
|
||||
"gear": gear,
|
||||
"speed_kmh": speed_kmh,
|
||||
"stationary": gear == "P" and speed_kmh is not None and abs(speed_kmh) <= 0.05,
|
||||
"complete": gear is not None and speed_kmh is not None,
|
||||
"frame_count": frame_count,
|
||||
"ignored_wrong_channel": ignored_wrong_channel,
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
def require_stationary_vehicle(snapshot: dict[str, Any]) -> None:
|
||||
"""Fail closed unless independent fresh CH1 frames prove P and 0 km/h."""
|
||||
|
||||
if snapshot.get("source") != PHYSICAL_SOURCE:
|
||||
raise VehicleSafetyPreflightError(
|
||||
"vehicle safety evidence is not authoritative physical CH1 feedback"
|
||||
)
|
||||
if snapshot.get("channel") != 1 or snapshot.get("read_only") is not True:
|
||||
raise VehicleSafetyPreflightError(
|
||||
"vehicle safety evidence was not captured read-only on CH1"
|
||||
)
|
||||
evidence = snapshot.get("evidence")
|
||||
if not isinstance(evidence, dict):
|
||||
raise VehicleSafetyPreflightError("vehicle safety evidence is missing")
|
||||
if evidence.get("gear", {}).get("frame_id") != "0x342":
|
||||
raise VehicleSafetyPreflightError("fresh CH1 0x342 gear evidence is missing")
|
||||
if evidence.get("speed", {}).get("frame_id") != "0x4EB":
|
||||
raise VehicleSafetyPreflightError("fresh CH1 0x4EB speed evidence is missing")
|
||||
if str(snapshot.get("gear", "")).upper() != "P":
|
||||
raise VehicleSafetyPreflightError("vehicle is not confirmed in P")
|
||||
speed = snapshot.get("speed_kmh")
|
||||
if (
|
||||
isinstance(speed, bool)
|
||||
or not isinstance(speed, (int, float))
|
||||
or abs(float(speed)) > 0.05
|
||||
):
|
||||
raise VehicleSafetyPreflightError(
|
||||
"vehicle speed is not confirmed at 0 km/h"
|
||||
)
|
||||
|
||||
|
||||
def merge_state(target: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively merge decoded feedback without discarding untouched devices."""
|
||||
|
||||
for key, value in update.items():
|
||||
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
||||
merge_state(target[key], value)
|
||||
else:
|
||||
target[key] = value
|
||||
return target
|
||||
Reference in New Issue
Block a user