DroneMind/voice_drone/core/flight_intent.py
2026-04-14 09:54:26 +08:00

339 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
flight_intent v1 校验与辅助(对齐 docs/FLIGHT_INTENT_SCHEMA_v1.md
兼容 Pydantic v2field_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]]:
"""
L1L3 校验。成功返回 (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