feat: add local hardware approval leases
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user