708 lines
39 KiB
Python
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())
|