#!/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())