""" flight_intent v1 校验与辅助(对齐 docs/FLIGHT_INTENT_SCHEMA_v1.md)。 兼容 Pydantic v2(field_validator / ConfigDict)。 互操作:部分云端会把「悬停 N 秒」写成 hover.args.duration;规范建议用 hover + wait.seconds。 解析时会折叠为 hover(无 duration)+ wait(seconds),与机端执行一致。 """ from __future__ import annotations import math from dataclasses import dataclass from typing import Any, List, Literal, Optional, Tuple, Union from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator _COORD_CAP = 10_000.0 def _check_coord(name: str, v: Optional[float]) -> Optional[float]: if v is None: return None if not isinstance(v, (int, float)) or not math.isfinite(float(v)): raise ValueError(f"{name} must be a finite number") fv = float(v) if abs(fv) > _COORD_CAP: raise ValueError(f"{name} out of range (|.| <= {_COORD_CAP})") return fv # --- args ----------------------------------------------------------------- class TakeoffArgs(BaseModel): model_config = ConfigDict(extra="forbid") relative_altitude_m: Optional[float] = None @field_validator("relative_altitude_m") @classmethod def _alt(cls, v: Optional[float]) -> Optional[float]: if v is not None and v <= 0: raise ValueError("relative_altitude_m must be > 0 when set") return v class EmptyArgs(BaseModel): model_config = ConfigDict(extra="forbid") class HoverHoldArgs(BaseModel): """hover / hold:规范仅 {}; 为兼容云端可带 duration(秒),解析后展开为 wait。""" model_config = ConfigDict(extra="forbid") duration: Optional[float] = None @field_validator("duration") @classmethod def _dur(cls, v: Optional[float]) -> Optional[float]: if v is None: return None fv = float(v) if not (0 < fv <= 3600): raise ValueError("duration must satisfy 0 < duration <= 3600 when set") return fv class WaitArgs(BaseModel): model_config = ConfigDict(extra="forbid") seconds: float @field_validator("seconds") @classmethod def _rng(cls, v: float) -> float: if not (0 < v <= 3600): raise ValueError("seconds must satisfy 0 < seconds <= 3600") return v class GotoArgs(BaseModel): model_config = ConfigDict(extra="forbid") frame: str x: Optional[float] = None y: Optional[float] = None z: Optional[float] = None @field_validator("frame") @classmethod def _frame(cls, v: str) -> str: if v not in ("local_ned", "body_ned"): raise ValueError('frame must be "local_ned" or "body_ned"') return v @field_validator("x", "y", "z") @classmethod def _coord(cls, v: Optional[float], info: ValidationInfo) -> Optional[float]: return _check_coord(str(info.field_name), v) # --- actions -------------------------------------------------------------- class ActionTakeoff(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["takeoff"] = "takeoff" args: TakeoffArgs class ActionLand(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["land"] = "land" args: EmptyArgs class ActionReturnHome(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["return_home"] = "return_home" args: EmptyArgs class ActionHover(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["hover"] = "hover" args: HoverHoldArgs class ActionHold(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["hold"] = "hold" args: HoverHoldArgs class ActionGoto(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["goto"] = "goto" args: GotoArgs class ActionWait(BaseModel): model_config = ConfigDict(extra="forbid") type: Literal["wait"] = "wait" args: WaitArgs FlightAction = Union[ ActionTakeoff, ActionLand, ActionReturnHome, ActionHover, ActionHold, ActionGoto, ActionWait, ] class FlightIntentPayload(BaseModel): model_config = ConfigDict(extra="forbid") is_flight_intent: bool version: int actions: List[Any] summary: str trace_id: Optional[str] = None @field_validator("is_flight_intent") @classmethod def _flag(cls, v: bool) -> bool: if v is not True: raise ValueError("is_flight_intent must be true") return v @field_validator("version") @classmethod def _ver(cls, v: int) -> int: if v != 1: raise ValueError("version must be 1") return v @field_validator("summary") @classmethod def _sum(cls, v: str) -> str: if not (isinstance(v, str) and v.strip()): raise ValueError("summary must be non-empty") return v @field_validator("trace_id") @classmethod def _tid(cls, v: Optional[str]) -> Optional[str]: if v is not None and len(v) > 128: raise ValueError("trace_id length must be <= 128") return v @field_validator("actions") @classmethod def _actions_nonempty(cls, v: List[Any]) -> List[Any]: if not isinstance(v, list) or len(v) == 0: raise ValueError("actions must be a non-empty array") return v @dataclass class ValidatedFlightIntent: summary: str trace_id: Optional[str] actions: List[FlightAction] def _parse_one_action(raw: dict) -> FlightAction: t = raw.get("type") if not isinstance(t, str): raise ValueError("action.type must be a string") args = raw.get("args") if not isinstance(args, dict): raise ValueError("action.args must be an object") if t == "takeoff": return ActionTakeoff(args=TakeoffArgs.model_validate(args)) if t == "land": return ActionLand(args=EmptyArgs.model_validate(args)) if t == "return_home": return ActionReturnHome(args=EmptyArgs.model_validate(args)) if t == "hover": return ActionHover(args=HoverHoldArgs.model_validate(args)) if t == "hold": return ActionHold(args=HoverHoldArgs.model_validate(args)) if t == "goto": return ActionGoto(args=GotoArgs.model_validate(args)) if t == "wait": return ActionWait(args=WaitArgs.model_validate(args)) raise ValueError(f"unknown action.type: {t!r}") def _expand_hover_duration(actions: List[FlightAction]) -> List[FlightAction]: """将 hover/hold 上附带的 duration 转为标准 wait(seconds)。""" out: List[FlightAction] = [] for a in actions: if isinstance(a, ActionHover): d = a.args.duration if d is not None: out.append(ActionHover(args=HoverHoldArgs())) out.append(ActionWait(args=WaitArgs(seconds=float(d)))) else: out.append(a) elif isinstance(a, ActionHold): d = a.args.duration if d is not None: out.append(ActionHold(args=HoverHoldArgs())) out.append(ActionWait(args=WaitArgs(seconds=float(d)))) else: out.append(a) else: out.append(a) return out def parse_flight_intent_dict(data: dict) -> Tuple[Optional[ValidatedFlightIntent], List[str]]: """ L1–L3 校验。成功返回 (ValidatedFlightIntent, []);失败返回 (None, [错误信息, ...])。 """ errors: List[str] = [] try: top = FlightIntentPayload.model_validate(data) except Exception as e: # noqa: BLE001 return None, [str(e)] parsed_actions: List[FlightAction] = [] for i, item in enumerate(top.actions): if not isinstance(item, dict): errors.append(f"actions[{i}] must be an object") continue try: parsed_actions.append(_parse_one_action(item)) except Exception as e: # noqa: BLE001 errors.append(f"actions[{i}]: {e}") if errors: return None, errors parsed_actions = _expand_hover_duration(parsed_actions) if isinstance(parsed_actions[0], ActionWait): return None, ["first action must not be wait (nothing to control yet)"] return ( ValidatedFlightIntent( summary=top.summary.strip(), trace_id=top.trace_id, actions=parsed_actions, ), [], ) def goto_action_to_command(action: ActionGoto, sequence_id: int) -> Tuple[Optional[Any], Optional[str]]: """ 将单轴 goto 映射为现有 Socket Command。 返回 (Command | None, error_reason | None)。 """ from voice_drone.core.command import Command a = action.args coords = [ ("x", a.x), ("y", a.y), ("z", a.z), ] active = [(name, val) for name, val in coords if val is not None and val != 0] if len(active) == 0: return None, "goto: all axes omit or zero (no-op)" if len(active) > 1: return ( None, f"goto: multi-axis ({', '.join(n for n, _ in active)}) not sent via Socket " "(use bridge or decompose)", ) name, val = active[0] dist = abs(float(val)) body_map = { "x": ("forward", "backward"), "y": ("right", "left"), "z": ("down", "up"), } pos, neg = body_map[name] cmd_name = pos if val > 0 else neg cmd = Command.create(cmd_name, sequence_id, distance=dist) cmd.fill_defaults() return cmd, None