feat: add local hardware approval leases

This commit is contained in:
vee
2026-08-03 10:20:55 +08:00
parent 85b4740d29
commit a952f37ceb
14 changed files with 1089 additions and 25 deletions
@@ -9,6 +9,11 @@ from .hardware import (
PHYSICAL_BENCH_COORDINATOR,
SeatHardwareSettings,
)
from .access import (
HardwareAccessController,
HardwareAccessRequiredError,
PHYSICAL_BENCH_ACCESS,
)
__all__ = [
"CanBus",
@@ -16,10 +21,13 @@ __all__ = [
"CanGatewayError",
"CanMatrix",
"HardwareBusyError",
"HardwareAccessController",
"HardwareAccessRequiredError",
"HardwareExecutionCoordinator",
"HardwareSafetyError",
"MatrixError",
"PHYSICAL_BENCH_COORDINATOR",
"PHYSICAL_BENCH_ACCESS",
"PythonCanBus",
"SeatHardwareSettings",
"ZlgCanBridgeBus",
@@ -0,0 +1,268 @@
"""Local approval leases for access to the single physical seat bench."""
from __future__ import annotations
import re
import threading
import time
from contextlib import contextmanager
from datetime import UTC, datetime
from typing import Any, Callable, Iterator
HARDWARE_CLIENT_ID_PATTERN = re.compile(r"^hwc-[a-z0-9-]{8,80}$")
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
class HardwareAccessRequiredError(PermissionError):
"""Raised before CAN is opened when a terminal has no active lease."""
def __init__(self, state: str = "none") -> None:
self.state = state
messages = {
"pending": "真实台架使用申请正在等待本地审批,本次请求未进入 CAN 链路。",
"expired": "真实台架使用权因空闲超过 3 分钟已自动停止,请重新申请。",
"none": "当前终端尚未获得真实台架使用权,请先向本地发送申请。",
}
super().__init__(messages.get(state, messages["none"]))
class HardwareAccessController:
"""Manage one locally-approved terminal and a bounded pending queue."""
def __init__(
self,
inactivity_timeout_seconds: int = 180,
max_pending: int = 32,
*,
clock: Callable[[], float] = time.monotonic,
timestamp: Callable[[], str] = _utc_now,
) -> None:
if inactivity_timeout_seconds <= 0:
raise ValueError("inactivity_timeout_seconds must be positive")
if max_pending <= 0:
raise ValueError("max_pending must be positive")
self.inactivity_timeout_seconds = inactivity_timeout_seconds
self.max_pending = max_pending
self._clock = clock
self._timestamp = timestamp
self._lock = threading.RLock()
self._pending: dict[str, dict[str, Any]] = {}
self._active: dict[str, Any] | None = None
self._expired_clients: set[str] = set()
@staticmethod
def validate_client_id(client_id: str | None) -> str:
value = (client_id or "").strip().lower()
if not HARDWARE_CLIENT_ID_PATTERN.fullmatch(value):
raise ValueError(
"hardware_client_id must start with hwc- and contain 8-80 lowercase letters, numbers, or hyphens"
)
return value
@staticmethod
def clean_label(label: str | None, client_id: str) -> str:
value = " ".join((label or "").strip().split())
if any(ord(character) < 32 for character in value):
raise ValueError("terminal label must not contain control characters")
return value[:64] or f"终端-{client_id[-6:]}"
def request_access(
self,
client_id: str | None,
label: str | None,
client_address: str,
) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
clean_label = self.clean_label(label, client_id)
with self._lock:
self._expire_locked()
self._expired_clients.discard(client_id)
if self._active and self._active["client_id"] == client_id:
return self._public_status_locked(client_id)
existing = self._pending.get(client_id)
if existing:
existing.update(label=clean_label, client_address=client_address)
else:
if len(self._pending) >= self.max_pending:
raise ValueError("hardware access request queue is full")
self._pending[client_id] = {
"client_id": client_id,
"label": clean_label,
"client_address": client_address,
"requested_at": self._timestamp(),
}
return self._public_status_locked(client_id)
def status(self, client_id: str | None) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
return self._public_status_locked(client_id)
def admin_snapshot(self) -> dict[str, Any]:
with self._lock:
self._expire_locked()
active = None
if self._active:
active = dict(self._active)
active.update(self._lease_timing_locked())
active.pop("last_activity_monotonic", None)
active.pop("active_executions", None)
return {
"policy": "local_approval_idle_lease",
"inactivity_timeout_seconds": self.inactivity_timeout_seconds,
"active": active,
"pending": [dict(record) for record in self._pending.values()],
}
def approve(self, client_id: str | None) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
request = self._pending.pop(client_id, None)
if request is None:
if self._active and self._active["client_id"] == client_id:
return self.admin_snapshot()
raise ValueError("hardware access request is no longer pending")
now = self._clock()
self._active = {
**request,
"approved_at": self._timestamp(),
"last_activity_at": self._timestamp(),
"last_activity_monotonic": now,
"active_executions": 0,
}
self._expired_clients.discard(client_id)
return self.admin_snapshot()
def deny(self, client_id: str | None) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._pending.pop(client_id, None)
self._expired_clients.discard(client_id)
return self.admin_snapshot()
def release(self, client_id: str | None) -> dict[str, Any]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
self._pending.pop(client_id, None)
if self._active and self._active["client_id"] == client_id:
self._active = None
self._expired_clients.discard(client_id)
return self._public_status_locked(client_id)
def revoke(self, client_id: str | None = None) -> dict[str, Any]:
with self._lock:
self._expire_locked()
if client_id:
client_id = self.validate_client_id(client_id)
if self._active and self._active["client_id"] != client_id:
raise ValueError("the selected terminal does not own the hardware lease")
self._active = None
return self.admin_snapshot()
@contextmanager
def use(self, client_id: str | None) -> Iterator[None]:
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
if not self._active or self._active["client_id"] != client_id:
raise HardwareAccessRequiredError(self._state_locked(client_id))
self._active["active_executions"] += 1
self._touch_locked()
try:
yield
finally:
with self._lock:
if self._active and self._active["client_id"] == client_id:
self._active["active_executions"] = max(
0, int(self._active["active_executions"]) - 1
)
self._touch_locked()
def ensure_authorized(self, client_id: str | None) -> str:
"""Validate a lease without refreshing it for a failed/busy request."""
client_id = self.validate_client_id(client_id)
with self._lock:
self._expire_locked()
if not self._active or self._active["client_id"] != client_id:
raise HardwareAccessRequiredError(self._state_locked(client_id))
return client_id
def public_info(self) -> dict[str, Any]:
with self._lock:
self._expire_locked()
return {
"access_policy": "local_approval_idle_lease",
"access_inactivity_timeout_seconds": self.inactivity_timeout_seconds,
"access_pending_count": len(self._pending),
"access_lease_active": self._active is not None,
}
def _touch_locked(self) -> None:
if self._active:
self._active["last_activity_monotonic"] = self._clock()
self._active["last_activity_at"] = self._timestamp()
def _expire_locked(self) -> None:
if not self._active:
return
if int(self._active.get("active_executions", 0)) > 0:
return
idle = self._clock() - float(self._active["last_activity_monotonic"])
if idle < self.inactivity_timeout_seconds:
return
self._expired_clients.add(str(self._active["client_id"]))
self._active = None
def _state_locked(self, client_id: str) -> str:
if self._active and self._active["client_id"] == client_id:
return "approved"
if client_id in self._pending:
return "pending"
if client_id in self._expired_clients:
return "expired"
return "none"
def _lease_timing_locked(self) -> dict[str, Any]:
if not self._active:
return {}
if int(self._active.get("active_executions", 0)) > 0:
return {
"idle_seconds": 0,
"idle_remaining_seconds": self.inactivity_timeout_seconds,
"active_execution": True,
}
idle = max(0.0, self._clock() - float(self._active["last_activity_monotonic"]))
return {
"idle_seconds": int(idle),
"idle_remaining_seconds": max(
0, self.inactivity_timeout_seconds - int(idle)
),
}
def _public_status_locked(self, client_id: str) -> dict[str, Any]:
state = self._state_locked(client_id)
result: dict[str, Any] = {
"state": state,
"client_id": client_id,
"inactivity_timeout_seconds": self.inactivity_timeout_seconds,
}
if state == "approved":
result.update(self._lease_timing_locked())
result["approved_at"] = self._active["approved_at"]
result["last_activity_at"] = self._active["last_activity_at"]
elif state == "pending":
pending_ids = list(self._pending)
result["queue_position"] = pending_ids.index(client_id) + 1
result["requested_at"] = self._pending[client_id]["requested_at"]
return result
PHYSICAL_BENCH_ACCESS = HardwareAccessController()