Files
Cognitive_Agent/apps/ir-toy-console/zlg_vehicle_gateway.py
T

708 lines
39 KiB
Python

#!/usr/bin/env python3
"""Windows ZLG CAN/CAN FD gateway for CAN_DEV's trusted command grammar.
This is a platform adapter for the Windows ``zlgcan.dll`` installation. Its
command whitelist, frame IDs, bit encodings, repeat intervals, feedback IDs,
and stop-on-exit behavior are ported from CAN_DEV's
``native/canfd/can_read_trans.cpp``. It deliberately exposes no raw-frame API.
"""
from __future__ import annotations
import argparse
import ctypes
import hashlib
import hmac
import json
import math
import os
import re
import threading
import time
from dataclasses import dataclass, field
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from urllib.parse import urlparse
ZCAN_TYPE_CANFD = 1
ZCAN_STATUS_OK = 1
MAX_BODY = 4096
class GatewayError(RuntimeError):
pass
class ZcanClassicInit(ctypes.Structure):
_fields_ = [("acc_code", ctypes.c_uint32), ("acc_mask", ctypes.c_uint32),
("reserved", ctypes.c_uint32), ("filter", ctypes.c_uint8),
("timing0", ctypes.c_uint8), ("timing1", ctypes.c_uint8),
("mode", ctypes.c_uint8)]
class ZcanFdInit(ctypes.Structure):
_fields_ = [("acc_code", ctypes.c_uint32), ("acc_mask", ctypes.c_uint32),
("abit_timing", ctypes.c_uint32), ("dbit_timing", ctypes.c_uint32),
("brp", ctypes.c_uint32), ("filter", ctypes.c_uint8),
("mode", ctypes.c_uint8), ("pad", ctypes.c_uint16),
("reserved", ctypes.c_uint32)]
class ZcanInitUnion(ctypes.Union):
_fields_ = [("can", ZcanClassicInit), ("canfd", ZcanFdInit)]
class ZcanChannelInit(ctypes.Structure):
_anonymous_ = ("config",)
_fields_ = [("can_type", ctypes.c_uint32), ("config", ZcanInitUnion)]
class ZcanFrame(ctypes.Structure):
_fields_ = [("can_id", ctypes.c_uint32), ("can_dlc", ctypes.c_uint8),
("pad", ctypes.c_uint8), ("reserved0", ctypes.c_uint8),
("reserved1", ctypes.c_uint8), ("data", ctypes.c_uint8 * 8)]
class ZcanFdFrame(ctypes.Structure):
_fields_ = [("can_id", ctypes.c_uint32), ("length", ctypes.c_uint8),
("flags", ctypes.c_uint8), ("reserved0", ctypes.c_uint8),
("reserved1", ctypes.c_uint8), ("data", ctypes.c_uint8 * 64)]
class ZcanTransmitData(ctypes.Structure):
_fields_ = [("frame", ZcanFrame), ("transmit_type", ctypes.c_uint32)]
class ZcanTransmitFdData(ctypes.Structure):
_fields_ = [("frame", ZcanFdFrame), ("transmit_type", ctypes.c_uint32)]
class ZcanReceiveData(ctypes.Structure):
_fields_ = [("frame", ZcanFrame), ("timestamp", ctypes.c_uint64)]
class ZcanReceiveFdData(ctypes.Structure):
_fields_ = [("frame", ZcanFdFrame), ("timestamp", ctypes.c_uint64)]
class ZcanChannelErrorInfo(ctypes.Structure):
_fields_ = [("error_code", ctypes.c_uint32),
("passive_error_data", ctypes.c_uint8 * 3),
("arbitration_lost_error_data", ctypes.c_uint8)]
def get_bits(data: bytes, start: int, width: int) -> int:
value = 0
for bit in range(width):
source = start + bit
if (data[source // 8] >> (source % 8)) & 1:
value |= 1 << bit
return value
def set_bits(payload: bytearray, start: int, width: int, value: int) -> None:
if width <= 0 or start + width > len(payload) * 8 or value < 0 or value >= 1 << width:
raise GatewayError("CAN signal value is out of range")
for bit in range(width):
target = start + bit
mask = 1 << (target % 8)
if (value >> bit) & 1:
payload[target // 8] |= mask
else:
payload[target // 8] &= ~mask
@dataclass(frozen=True)
class FrameStep:
arbitration_id: int
data: bytes
fd: bool
repeat: int = 1
interval_ms: int = 0
label: str = ""
@dataclass
class CompiledCommand:
command: str
domain: str
steps: List[FrameStep]
expected_ids: Tuple[int, ...] = ()
timeout_ms: int = 1500
matcher: Optional[Callable[[Dict[int, dict]], bool]] = None
final_steps: List[FrameStep] = field(default_factory=list)
WINDOW_BITS = {"front-left": 48, "rear-left": 56, "front-right": 51, "rear-right": 59}
WINDOW_INDEX = {"front-left": 0, "rear-left": 1, "front-right": 2, "rear-right": 3}
WINDOW_SCOPES = {
"all": tuple(WINDOW_BITS), "front": ("front-left", "front-right"),
"rear": ("rear-left", "rear-right"),
**{name: (name,) for name in WINDOW_BITS},
}
OUTLET_AXES = {
"driver-left": (0x02, 24, 8), "driver-right": (0x02, 32, 16),
"passenger-left": (0x03, 24, 8), "passenger-right": (0x03, 32, 16),
}
OUTLET_FEEDBACK_AXES = {
0x01: ("driver-left", "left-right"),
0x02: ("driver-left", "up-down"),
0x03: ("driver-right", "left-right"),
0x04: ("driver-right", "up-down"),
0x05: ("passenger-left", "left-right"),
0x06: ("passenger-left", "up-down"),
0x07: ("passenger-right", "left-right"),
0x08: ("passenger-right", "up-down"),
}
OUTLET_MODE_BITS = {
"person": (8, 10), "avoid": (12, 14), "sweep-up-down": (16, 20),
"sweep-left-right": (18, 22), "free": (24, 26), "close": (28, 30),
}
def _window_payload(names: Sequence[str], motion: int) -> bytes:
payload = bytearray(8)
for name in names:
set_bits(payload, WINDOW_BITS[name], 3, motion)
return bytes(payload)
def _repeat_step(identifier: int, data: bytes, *, fd: bool, repeat: int = 3,
interval_ms: int = 100, label: str = "") -> FrameStep:
return FrameStep(identifier, data, fd, repeat, interval_ms, label)
def _decode_state(identifier: int, data: bytes) -> dict:
state: dict = {"frame_id": f"0x{identifier:03X}", "data_hex": data.hex(" ").upper()}
if len(data) < 8:
return state
if identifier == 0x407:
state.update({"domain": "door", "front_left": get_bits(data, 0, 2),
"front_right": get_bits(data, 2, 2), "rear_left": get_bits(data, 4, 2),
"rear_right": get_bits(data, 6, 2), "logical": get_bits(data, 10, 2)})
elif identifier == 0x388:
state.update({"domain": "window", "positions": [value if value <= 100 else None for value in data[2:6]],
"closed": [get_bits(data, 48 + index * 2, 2) for index in range(4)]})
elif identifier == 0x404:
state.update({"domain": "climate", "power": get_bits(data, 8, 2),
"control": get_bits(data, 10, 2), "mode": get_bits(data, 16, 4),
"fan": get_bits(data, 20, 4), "driver_temp": data[4],
"passenger_temp": data[5], "rear_temp": data[6]})
elif identifier == 0x4FA:
state.update({"domain": "seat", "vent": get_bits(data, 0, 3),
"heat": get_bits(data, 3, 3), "massage_mode": get_bits(data, 40, 4),
"massage_strength": get_bits(data, 44, 4)})
elif identifier == 0x494:
state.update({"domain": "seat_position", "initialized": [get_bits(data, bit, 1) for bit in (58, 59, 60, 61)]})
elif identifier == 0x42E:
state.update({"domain": "light", "front_brightness": get_bits(data, 0, 4),
"rear_brightness": get_bits(data, 4, 4), "front_color": get_bits(data, 8, 7),
"rear_color": get_bits(data, 15, 7), "ambient": get_bits(data, 32, 3),
"indoor": get_bits(data, 37, 2)})
elif identifier == 0x41A:
position = data[1]
state.update({"domain": "shade", "position": position if position <= 100 else None,
"initialized": get_bits(data, 18, 2), "closed": get_bits(data, 22, 2),
"action": get_bits(data, 27, 3), "location": get_bits(data, 44, 4),
"thermal_protection": get_bits(data, 49, 1)})
elif identifier == 0x342:
raw = get_bits(data, 24, 4)
state.update({"domain": "vehicle_gear", "gear_raw": raw,
"gear": {1: "P", 2: "R", 3: "N", 4: "D"}.get(raw, "invalid")})
elif identifier == 0x4EB and data[0] == 0x07:
raw = get_bits(data, 8, 12)
state.update({"domain": "vehicle_speed", "speed_raw": raw,
"speed_kmh": None if raw == 0xFFF else round(raw * 0.06875, 3)})
elif identifier == 0x2F5 and data[0] in OUTLET_FEEDBACK_AXES:
outlet, axis = OUTLET_FEEDBACK_AXES[data[0]]
position = get_bits(data, 32, 16)
response_error = get_bits(data, 8, 1)
overheat = get_bits(data, 10, 2)
electrical_fault = get_bits(data, 12, 2)
blocked = get_bits(data, 20, 2)
state.update({
"domain": "outlet",
"outlet": outlet,
"axis": axis,
"position": None if position == 0x7FFF else position,
"speed_state": get_bits(data, 28, 4),
"stop_mode": get_bits(data, 62, 2),
"fault": bool(response_error or overheat or electrical_fault or blocked),
"fault_detail": {
"response_error": response_error,
"overheat": overheat,
"electrical": electrical_fault,
"blocked": blocked,
},
})
return state
class ZlgDriver:
def __init__(self, args: argparse.Namespace):
self.dll = ctypes.WinDLL(args.dll)
self._declare()
self.device = self.dll.ZCAN_OpenDevice(args.device_type, args.device_index, 0)
if not self.device:
raise GatewayError("ZCAN_OpenDevice failed; connect USBCANFD-200U and close ZCANPRO")
self.channel = None
self.args = args
self.lock = threading.Lock()
self.condition = threading.Condition(self.lock)
self.latest: Dict[int, dict] = {}
self.sequence = 0
self.rx_count = 0
self.tx_count = 0
self.stop_event = threading.Event()
try:
prefix = str(args.channel)
if self.dll.ZCAN_SetValue(self.device, (prefix + "/canfd_abit_baud_rate").encode(), str(args.bitrate).encode()) != 1:
raise GatewayError("ZCAN_SetValue(canfd_abit_baud_rate) failed")
if self.dll.ZCAN_SetValue(self.device, (prefix + "/canfd_dbit_baud_rate").encode(), str(args.data_bitrate).encode()) != 1:
raise GatewayError("ZCAN_SetValue(canfd_dbit_baud_rate) failed")
init = ZcanChannelInit()
init.can_type = ZCAN_TYPE_CANFD
init.canfd.acc_code = 0
init.canfd.acc_mask = 0xFFFFFFFF
init.canfd.filter = 0
init.canfd.mode = 0
self.channel = self.dll.ZCAN_InitCAN(self.device, args.channel, ctypes.byref(init))
if not self.channel or self.dll.ZCAN_StartCAN(self.channel) != 1:
raise GatewayError("ZCAN CAN FD channel initialization failed")
self.dll.ZCAN_ClearBuffer(self.channel)
self.receiver = threading.Thread(target=self._receive_loop, name="zlg-canfd-rx", daemon=True)
self.receiver.start()
except Exception:
self.close()
raise
def _declare(self) -> None:
handle = ctypes.c_void_p
declarations = {
"ZCAN_OpenDevice": ([ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32], handle),
"ZCAN_CloseDevice": ([handle], ctypes.c_uint32),
"ZCAN_SetValue": ([handle, ctypes.c_char_p, ctypes.c_char_p], ctypes.c_uint32),
"ZCAN_InitCAN": ([handle, ctypes.c_uint32, ctypes.POINTER(ZcanChannelInit)], handle),
"ZCAN_StartCAN": ([handle], ctypes.c_uint32), "ZCAN_ResetCAN": ([handle], ctypes.c_uint32),
"ZCAN_ClearBuffer": ([handle], ctypes.c_uint32),
"ZCAN_Transmit": ([handle, ctypes.POINTER(ZcanTransmitData), ctypes.c_uint32], ctypes.c_uint32),
"ZCAN_Receive": ([handle, ctypes.POINTER(ZcanReceiveData), ctypes.c_uint32, ctypes.c_int32], ctypes.c_uint32),
"ZCAN_TransmitFD": ([handle, ctypes.POINTER(ZcanTransmitFdData), ctypes.c_uint32], ctypes.c_uint32),
"ZCAN_ReceiveFD": ([handle, ctypes.POINTER(ZcanReceiveFdData), ctypes.c_uint32, ctypes.c_int32], ctypes.c_uint32),
"ZCAN_ReadChannelErrInfo": ([handle, ctypes.POINTER(ZcanChannelErrorInfo)], ctypes.c_uint32),
}
for name, (arguments, result) in declarations.items():
if not hasattr(self.dll, name):
raise GatewayError("zlgcan.dll missing required export: " + name)
function = getattr(self.dll, name)
function.argtypes, function.restype = arguments, result
def _receive_loop(self) -> None:
classic = (ZcanReceiveData * 100)()
canfd = (ZcanReceiveFdData * 100)()
while not self.stop_event.is_set():
received = False
try:
classic_count = int(self.dll.ZCAN_Receive(self.channel, classic, 100, 0))
for index in range(classic_count):
frame = classic[index].frame
self._record(int(frame.can_id & 0x1FFFFFFF), bytes(frame.data[:frame.can_dlc]), False, int(classic[index].timestamp))
received = True
fd_count = int(self.dll.ZCAN_ReceiveFD(self.channel, canfd, 100, 0))
for index in range(fd_count):
frame = canfd[index].frame
self._record(int(frame.can_id & 0x1FFFFFFF), bytes(frame.data[:frame.length]), True, int(canfd[index].timestamp))
received = True
except Exception:
self.stop_event.set()
break
if not received:
time.sleep(0.002)
def _record(self, identifier: int, data: bytes, fd: bool, timestamp: int) -> None:
decoded = _decode_state(identifier, data)
with self.condition:
self.sequence += 1
self.rx_count += 1
decoded.update({"sequence": self.sequence, "can_fd": fd, "timestamp_us": timestamp,
"observed_at": time.time()})
if identifier == 0x2F5 and decoded.get("domain") == "outlet":
previous_axes = dict((self.latest.get(identifier) or {}).get("axes") or {})
axis_key = f"{decoded['outlet']}.{decoded['axis']}"
previous_axes[axis_key] = dict(decoded)
decoded["axes"] = previous_axes
self.latest[identifier] = decoded
self.condition.notify_all()
def send(self, step: FrameStep) -> list[dict]:
sent: list[dict] = []
for index in range(step.repeat):
if step.fd:
transmit = ZcanTransmitFdData()
transmit.frame.can_id = step.arbitration_id
transmit.frame.length = len(step.data)
transmit.frame.flags = 0x01 # BRS
for offset, value in enumerate(step.data):
transmit.frame.data[offset] = value
count = self.dll.ZCAN_TransmitFD(self.channel, ctypes.byref(transmit), 1)
else:
transmit = ZcanTransmitData()
transmit.frame.can_id = step.arbitration_id
transmit.frame.can_dlc = len(step.data)
for offset, value in enumerate(step.data):
transmit.frame.data[offset] = value
count = self.dll.ZCAN_Transmit(self.channel, ctypes.byref(transmit), 1)
if count != 1:
error = ZcanChannelErrorInfo()
detail = ""
if self.dll.ZCAN_ReadChannelErrInfo(self.channel, ctypes.byref(error)) == 1:
detail = f" channel_error=0x{error.error_code:08X}"
raise GatewayError("ZLG adapter did not accept CAN frame;" + detail)
self.tx_count += 1
sent.append({"frame_id": f"0x{step.arbitration_id:03X}", "data_hex": step.data.hex(" ").upper(),
"can_fd": step.fd, "brs": step.fd, "attempt": index + 1})
if index + 1 < step.repeat and step.interval_ms:
time.sleep(step.interval_ms / 1000.0)
return sent
def snapshot(self) -> dict:
with self.lock:
states = {f"0x{identifier:03X}": dict(value) for identifier, value in self.latest.items()
if value.get("domain")}
return {"rx_count": self.rx_count, "tx_count": self.tx_count, "sequence": self.sequence,
"states": states, "last_receive_age_ms": min(
[round((time.time() - item["observed_at"]) * 1000) for item in self.latest.values()] or [-1])}
def wait_feedback(self, plan: CompiledCommand, baseline: int) -> tuple[bool, bool, dict]:
deadline = time.monotonic() + plan.timeout_ms / 1000.0
observed = False
latest_by_id: Dict[int, dict] = {}
with self.condition:
while time.monotonic() < deadline:
latest_by_id = {identifier: dict(self.latest[identifier]) for identifier in plan.expected_ids
if identifier in self.latest and self.latest[identifier]["sequence"] > baseline}
observed = bool(latest_by_id)
if observed and plan.matcher is None:
# Some command families expose only an acknowledgement or
# an undecoded status frame. Record that feedback was
# observed, but never claim the requested target matched.
return True, False, {f"0x{k:03X}": v for k, v in latest_by_id.items()}
if observed and plan.matcher(latest_by_id):
return True, True, {f"0x{k:03X}": v for k, v in latest_by_id.items()}
self.condition.wait(timeout=min(0.1, max(0.0, deadline - time.monotonic())))
return observed, False, {f"0x{k:03X}": v for k, v in latest_by_id.items()}
def close(self) -> None:
self.stop_event.set()
if getattr(self, "receiver", None):
self.receiver.join(timeout=1)
if getattr(self, "channel", None):
self.dll.ZCAN_ResetCAN(self.channel)
self.channel = None
if getattr(self, "device", None):
self.dll.ZCAN_CloseDevice(self.device)
self.device = None
def _load_calibration(path: Path) -> dict:
try:
axes = json.loads(path.read_text(encoding="utf-8"))["axes"]
except (OSError, KeyError, TypeError, json.JSONDecodeError) as exc:
raise GatewayError("air outlet calibration is unavailable") from exc
return axes
def compile_command(command: str, latest: Dict[int, dict], calibration: dict) -> CompiledCommand:
if not command or len(command) > 256 or "\n" in command or "\r" in command:
raise GatewayError("invalid command")
words = command.lower().split()
if not words or words[0] not in {"door", "window", "ac", "outlet", "seat", "light", "shade"}:
raise GatewayError("unsupported command family")
if words[:2] == ["door", "rear"] and len(words) == 3 and words[2] in {"lock", "unlock"}:
payload = bytearray(8); payload[0] = 0x0A; value = 1 if words[2] == "lock" else 2
set_bits(payload, 14, 2, value)
matcher = lambda states, value=value: states.get(0x407, {}).get("rear_left") == (2 if value == 1 else 1) and states.get(0x407, {}).get("rear_right") == (2 if value == 1 else 1)
return CompiledCommand(command, "door", [_repeat_step(0x32B, bytes(payload), fd=False, repeat=1)], (0x407,), matcher=matcher)
if words[0] == "window" and len(words) in {3, 4} and words[1] in WINDOW_SCOPES:
names = WINDOW_SCOPES[words[1]]
if len(words) == 3 and words[2] in {"open", "close", "stop"}:
motion = {"open": 1, "close": 2, "stop": 3}[words[2]]
step = _repeat_step(0x431, _window_payload(names, motion), fd=True, label="window")
if words[2] == "stop":
return CompiledCommand(command, "window", [step])
expected = 2 if words[2] == "open" else 1
indexes = [WINDOW_INDEX[name] for name in names]
matcher = lambda states, indexes=indexes, expected=expected: all(states.get(0x388, {}).get("closed", [None] * 4)[index] == expected for index in indexes)
return CompiledCommand(command, "window", [step], (0x388,), 8000, matcher)
if len(words) == 4 and words[2] == "position" and words[1] in WINDOW_INDEX:
try: target = int(words[3])
except ValueError: raise GatewayError("window percentage must be an integer")
if not 0 <= target <= 100:
raise GatewayError("window percentage is out of range")
current_state = latest.get(0x388) or {}
index = WINDOW_INDEX[words[1]]
positions = current_state.get("positions") or [None] * 4
if positions[index] is None or time.time() - current_state.get("observed_at", 0) > 2.5:
raise GatewayError("fresh 0x388 window position is required")
if abs(positions[index] - target) <= 3:
return CompiledCommand(command, "window", [], (0x388,), 0, lambda states: True)
motion = 1 if target > positions[index] else 2
main = _repeat_step(0x431, _window_payload((words[1],), motion), fd=True)
stop = _repeat_step(0x431, _window_payload((words[1],), 3), fd=True)
matcher = lambda states, index=index, target=target, motion=motion: (
states.get(0x388, {}).get("positions", [None] * 4)[index] is not None and
(states[0x388]["positions"][index] >= target - 3 if motion == 1 else states[0x388]["positions"][index] <= target + 3))
return CompiledCommand(command, "window", [main], (0x388,), 8000, matcher, [stop])
if words[0] == "ac":
payload = bytearray(8); repeat = 3; matcher = None
if len(words) == 2 and words[1] in {"on", "off"}:
value = 1 if words[1] == "on" else 2; set_bits(payload, 28, 2, value)
matcher = lambda states, value=value: states.get(0x404, {}).get("power") == value
elif len(words) == 3 and words[1] == "fan" and words[2].isdigit() and 1 <= int(words[2]) <= 7:
value = int(words[2]); set_bits(payload, 4, 4, value)
matcher = lambda states, value=value: states.get(0x404, {}).get("fan") == value
elif len(words) == 3 and words[1] == "mode" and words[2] in {"face", "face-feet", "feet", "feet-defrost", "defrost", "face-feet-defrost", "face-defrost"}:
value = ["", "face", "face-feet", "feet", "feet-defrost", "defrost", "face-feet-defrost", "face-defrost"].index(words[2]); set_bits(payload, 0, 4, value)
matcher = lambda states, value=value: states.get(0x404, {}).get("mode") == value
elif len(words) == 4 and words[1] == "temp" and words[2] in {"driver", "passenger", "rear"} and words[3].isdigit() and 18 <= int(words[3]) <= 32:
value = int(words[3]); offset = {"driver": 4, "passenger": 5, "rear": 6}[words[2]]; payload[offset] = value
repeat = 1 if words[2] == "rear" else 3
matcher = lambda states, field=words[2] + "_temp", value=value: states.get(0x404, {}).get(field) == value
elif len(words) == 3 and words[1] == "control" and words[2] in {"manual", "auto"}:
value = 1 if words[2] == "manual" else 2; set_bits(payload, 16, 2, value)
matcher = lambda states, value=value: states.get(0x404, {}).get("control") == value
else:
raise GatewayError("invalid AC command")
return CompiledCommand(command, "climate", [_repeat_step(0x1DE, bytes(payload), fd=False, repeat=repeat)], (0x404,), matcher=matcher)
if words[0] == "outlet":
if len(words) == 5 and words[1] in OUTLET_AXES and words[2] in {"percent", "position"}:
try: horizontal, vertical = int(words[3]), int(words[4])
except ValueError: raise GatewayError("outlet coordinates must be integers")
ranges = [calibration.get(words[1] + ".left-right"), calibration.get(words[1] + ".up-down")]
if not all(ranges):
raise GatewayError("outlet calibration is incomplete")
if words[2] == "percent":
if not (0 <= horizontal <= 100 and 0 <= vertical <= 100):
raise GatewayError("outlet percentage is out of range")
horizontal = ranges[0]["minimum"] + ((ranges[0]["maximum"] - ranges[0]["minimum"]) * horizontal + 50) // 100
vertical = ranges[1]["minimum"] + ((ranges[1]["maximum"] - ranges[1]["minimum"]) * vertical + 50) // 100
elif not (ranges[0]["minimum"] <= horizontal <= ranges[0]["maximum"] and ranges[1]["minimum"] <= vertical <= ranges[1]["maximum"]):
raise GatewayError("outlet position is outside the calibrated range")
sub_id, horizontal_bit, vertical_bit = OUTLET_AXES[words[1]]
payload = bytearray([sub_id, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0xFF if sub_id == 2 else 0, 0xFF if sub_id == 2 else 0])
set_bits(payload, horizontal_bit, 8, horizontal + 128); set_bits(payload, vertical_bit, 8, vertical + 128)
return CompiledCommand(command, "outlet", [_repeat_step(0x4EF, bytes(payload), fd=True)], (0x2F5, 0x37B), 1500)
if len(words) >= 4 and words[1:3] in (["mode", "driver"], ["mode", "passenger"]):
side = 0 if words[2] == "driver" else 1
if len(words) == 4 and words[3] in {"open", "close"}:
signal, raw = "close", 2 if words[3] == "open" else 1
elif len(words) == 5 and words[3] in {"person", "avoid", "free"} and words[4] in {"on", "off"}:
signal, raw = words[3], 1 if words[4] == "on" else 2
elif len(words) == 6 and words[3] == "sweep" and words[4] in {"up-down", "left-right"} and words[5] in {"start", "stop"}:
signal, raw = "sweep-" + words[4], 1 if words[5] == "start" else 2
else:
raise GatewayError("invalid outlet mode command")
payload = bytearray(8); payload[0] = 0x04; set_bits(payload, OUTLET_MODE_BITS[signal][side], 2, raw)
return CompiledCommand(command, "outlet", [_repeat_step(0x4EF, bytes(payload), fd=True, repeat=1)], (0x4BB, 0x4F1, 0x37B), 1500)
if words[:2] == ["seat", "driver"]:
if len(words) == 4 and words[2] in {"vent", "heat"} and words[3] in {"off", "low", "high"}:
control = {"high": 1, "low": 2, "off": 3}[words[3]]; payload = bytearray(8)
payload[1] = control << 4 if words[2] == "heat" else control
expected = {1: 3, 2: 2, 3: 1}[control]; field = words[2]
matcher = lambda states, field=field, expected=expected: states.get(0x4FA, {}).get(field) == expected
return CompiledCommand(command, "seat", [_repeat_step(0x431, bytes(payload), fd=True)], (0x4FA,), matcher=matcher)
if len(words) in {4, 5} and words[2] in {"horizontal", "height", "backrest", "basin"}:
axis_bits = {"horizontal": 8, "height": 10, "backrest": 12, "basin": 14}
allowed = {"horizontal": ("forward", "backward"), "backrest": ("forward", "backward"),
"height": ("up", "down"), "basin": ("up", "down")}
payload = bytearray(8); payload[0] = 0x05
stop_payload = bytes(payload)
if words[3] == "stop" and len(words) == 4:
return CompiledCommand(command, "seat", [_repeat_step(0x4C1, stop_payload, fd=True)])
if words[3] not in allowed[words[2]]:
raise GatewayError("invalid seat direction")
duration = int(words[4]) if len(words) == 5 and words[4].isdigit() else 500
if not 100 <= duration <= 2000:
raise GatewayError("seat duration must be 100..2000 ms")
direction = 1 if words[3] in {"forward", "up"} else 2
set_bits(payload, axis_bits[words[2]], 2, direction)
main = _repeat_step(0x4C1, bytes(payload), fd=True, repeat=math.ceil(duration / 100))
stop = _repeat_step(0x4C1, stop_payload, fd=True)
return CompiledCommand(command, "seat", [main], (0x494,), 1500, None, [stop])
if len(words) == 4 and words[2:4] == ["massage", "off"]:
payload = bytes([0x12, 0, 0, 1, 0, 0, 0, 0])
matcher = lambda states: states.get(0x4FA, {}).get("massage_mode") == 1
return CompiledCommand(command, "seat", [_repeat_step(0x4C1, payload, fd=True)], (0x4FA,), matcher=matcher)
if len(words) == 5 and words[2:4] == ["massage", "strength"] and words[4] in {"weak", "medium", "strong"}:
value = {"weak": 1, "medium": 2, "strong": 3}[words[4]]; payload = bytes([0x12, value, 0, 0, 0, 0, 0, 0])
matcher = lambda states, value=value: states.get(0x4FA, {}).get("massage_strength") == value + 8
return CompiledCommand(command, "seat", [_repeat_step(0x4C1, payload, fd=True)], (0x4FA,), matcher=matcher)
if len(words) == 5 and words[2:4] == ["massage", "mode"] and words[4] in {"wave", "pulse", "stretch", "slack", "relax"}:
value = {"wave": 2, "pulse": 3, "stretch": 4, "slack": 5, "relax": 6}[words[4]]; payload = bytes([0x12, 0, 0, value, 0, 0, 0, 0])
matcher = lambda states, value=value: states.get(0x4FA, {}).get("massage_mode") == value
return CompiledCommand(command, "seat", [_repeat_step(0x4C1, payload, fd=True)], (0x4FA,), matcher=matcher)
if words[0] == "light":
if (len(words) == 3 and words[1] == "indoor" and words[2] in {"on", "off"}) or (len(words) == 4 and words[1:3] == ["reading", "all"] and words[3] in {"on", "off"}):
on = words[-1] == "on"; payload = bytearray([0, 0, 3, 0xFF, 0xFF, 0, 0, 0]); set_bits(payload, 50, 2, 2 if on else 1)
matcher = lambda states, expected=2 if on else 1: states.get(0x42E, {}).get("indoor") == expected
return CompiledCommand(command, "light", [_repeat_step(0x4F5, bytes(payload), fd=True)], (0x42E,), matcher=matcher)
if len(words) == 3 and words[1] == "ambient" and words[2] in {"on", "off"}:
on = words[2] == "on"; payload = bytes([0, 0x36 if on else 0x31, 0, 0x75, 0, 0x80, 0, 0])
matcher = lambda states, on=on: ((2 <= states.get(0x42E, {}).get("front_brightness", 0) <= 6 and 2 <= states.get(0x42E, {}).get("rear_brightness", 0) <= 6) if on else (states.get(0x42E, {}).get("front_brightness") == 1 and states.get(0x42E, {}).get("rear_brightness") == 1))
return CompiledCommand(command, "light", [_repeat_step(0x3FC, payload, fd=True)], (0x42E,), matcher=matcher)
if len(words) == 4 and words[1:3] == ["ambient", "brightness"] and words[3].isdigit() and 1 <= int(words[3]) <= 5:
level = int(words[3]); payload = bytes([0, 0x30 | (level + 1), 0, 0x75, 0, 0, 0, 0])
matcher = lambda states, raw=level + 1: states.get(0x42E, {}).get("front_brightness") == raw and states.get(0x42E, {}).get("rear_brightness") == raw
return CompiledCommand(command, "light", [_repeat_step(0x3FC, payload, fd=True)], (0x42E,), matcher=matcher)
if len(words) == 4 and words[1:3] == ["ambient", "color"] and words[3].isdigit() and 1 <= int(words[3]) <= 127:
color = int(words[3]); payload = bytes([color, 0x30, 0, 0x75, 0, 0, 0, 0])
matcher = lambda states, color=color: states.get(0x42E, {}).get("front_color") == color and states.get(0x42E, {}).get("rear_color") == color
return CompiledCommand(command, "light", [_repeat_step(0x3FC, payload, fd=True)], (0x42E,), matcher=matcher)
if words[0] == "shade":
motion_name = None
if len(words) == 2 and words[1] in {"open", "close", "half", "stop"}:
motion_name = words[1]
elif len(words) == 3 and words[1] == "position" and words[2] in {"0", "50", "100"}:
motion_name = {"0": "close", "50": "half", "100": "open"}[words[2]]
if motion_name:
motion = {"open": 1, "close": 2, "half": 3, "stop": 4}[motion_name]
payload = bytearray(8); set_bits(payload, 4, 4, motion)
if motion_name == "stop":
return CompiledCommand(command, "shade", [_repeat_step(0x431, bytes(payload), fd=False, repeat=1)])
target = {"open": 100, "close": 0, "half": 50}[motion_name]
matcher = lambda states, target=target: states.get(0x41A, {}).get("position") is not None and (states[0x41A]["position"] >= 98 if target == 100 else states[0x41A]["position"] <= 2 if target == 0 else 47 <= states[0x41A]["position"] <= 53)
return CompiledCommand(command, "shade", [_repeat_step(0x431, bytes(payload), fd=False, repeat=1)], (0x41A,), 15000, matcher)
raise GatewayError("command syntax or value is outside the trusted CAN_DEV whitelist")
class GatewayService:
def __init__(self, driver: ZlgDriver, *, control: bool, token: str, calibration: dict):
self.driver, self.control, self.token, self.calibration = driver, control, token, calibration
self.command_lock = threading.Lock()
def runtime(self) -> dict:
snapshot = self.driver.snapshot()
return {"service": "cognitive-agent-native-can-windows", "running": not self.driver.stop_event.is_set(),
"mode": "control" if self.control else "monitor_only", "control_token_configured": bool(self.token),
"adapter": {"model": "USBCANFD-200U", "device_type": self.driver.args.device_type,
"device_index": self.driver.args.device_index, "channel": self.driver.args.channel,
"arbitration_bitrate": self.driver.args.bitrate, "data_bitrate": self.driver.args.data_bitrate,
"termination_changed": False}, **snapshot}
def validate(self, command: str) -> dict:
compile_command(command, self.driver.latest, self.calibration)
return {"valid": True, "command": command, "transmitted": False,
"validator": "CAN_DEV can_read_trans whitelist / Windows ZLG adapter"}
def execute(self, command: str, token: str) -> dict:
if not self.control:
raise PermissionError("CAN gateway is in monitor-only mode")
if not self.token or not hmac.compare_digest(token, self.token):
raise PermissionError("CAN approval token is missing or invalid")
with self.command_lock:
plan = compile_command(command, self.driver.latest, self.calibration)
baseline = self.driver.sequence
frames: list = []
try:
for step in plan.steps:
frames.extend(self.driver.send(step))
observed, matched, feedback = self.driver.wait_feedback(plan, baseline) if plan.expected_ids and plan.timeout_ms else (False, False, {})
finally:
for step in plan.final_steps:
frames.extend(self.driver.send(step))
return {"accepted": True, "command": command,
"message": "CAN 命令已提交并收到目标状态反馈(不等同于实体机构确认)。" if matched else "CAN 命令已由适配器提交;尚未确认目标状态。",
"execution": {"domain": plan.domain, "sdk_submitted": True, "feedback_observed": observed,
"feedback_matched": matched, "target_state_confirmed": matched,
"physical_state_confirmed": False,
"frames_sent": frames, "feedback": feedback}}
SERVICE: Optional[GatewayService] = None
class Handler(BaseHTTPRequestHandler):
server_version = "CognitiveAgentZlgGateway/1.0"
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/api/can/health":
self._json({"status": "ok", "service": "cognitive-agent-native-can-windows"})
elif path in {"/api/can/runtime", "/api/can/status"}:
self._json(SERVICE.runtime())
else:
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
try:
length = int(self.headers.get("Content-Length", "0"))
if not 0 < length <= MAX_BODY:
raise GatewayError("invalid request size")
command = self.rfile.read(length).decode("utf-8").strip()
if path == "/api/can/validate":
self._json(SERVICE.validate(command))
elif path == "/api/can/command":
self._json(SERVICE.execute(command, self.headers.get("X-CAN-Approval-Token", "")))
else:
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
except PermissionError as exc:
self._json({"error": "hardware_safety_gate", "message": str(exc)}, HTTPStatus.FORBIDDEN)
except (GatewayError, UnicodeDecodeError, ValueError) as exc:
self._json({"valid": False, "error": "invalid_command", "message": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self._json({"error": "gateway_failure", "message": str(exc)}, HTTPStatus.SERVICE_UNAVAILABLE)
def log_message(self, message: str, *args: Any) -> None:
print("[HTTP] " + message % args, flush=True)
def _json(self, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self.send_response(status); self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body))); self.send_header("Cache-Control", "no-store")
self.end_headers(); self.wfile.write(body)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Windows ZLG CAN_DEV native gateway")
parser.add_argument("--dll", default=r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll")
parser.add_argument("--device-type", type=int, default=41); parser.add_argument("--device-index", type=int, default=0)
parser.add_argument("--channel", type=int, choices=(0, 1), default=0); parser.add_argument("--bitrate", type=int, default=500000)
parser.add_argument("--data-bitrate", type=int, default=2000000); parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765); parser.add_argument("--control", action="store_true")
parser.add_argument("--calibration", default=str(Path(__file__).with_name("air_outlet_calibration.json")))
return parser.parse_args()
def main() -> int:
global SERVICE
args = parse_args(); token = os.environ.get("COGNITIVE_AGENT_NATIVE_CAN_APPROVAL_TOKEN", "").strip()
if args.control and len(token) < 6:
raise SystemExit("--control requires COGNITIVE_AGENT_NATIVE_CAN_APPROVAL_TOKEN with at least 6 characters")
calibration = _load_calibration(Path(args.calibration)); driver = ZlgDriver(args)
SERVICE = GatewayService(driver, control=args.control, token=token, calibration=calibration)
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"Windows ZLG native CAN gateway: http://{args.host}:{args.port} ({'control' if args.control else 'monitor-only'})", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close(); driver.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())