@lumikmz/kmz 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it } from "vite-plus/test";
2
2
  import { degrees, meters, metersPerSecond } from "../types/branded.js";
3
3
  import type { TargetDetectionTemplate } from "../types/template.js";
4
4
  import { plan } from "./index.js";
@@ -54,6 +54,49 @@ function template(overrides: { pitch?: number; direction?: number } = {}): Targe
54
54
  }
55
55
 
56
56
  describe("plan(targetdetection)", () => {
57
+ it("matches FlightHub recording and target-detection actions without stopRecord", () => {
58
+ const input = template();
59
+ Object.assign(input.Folder.Placemark, {
60
+ recordEnable: true,
61
+ targetDetectionActionEnable: true,
62
+ action: {
63
+ actionId: 0,
64
+ actionActuatorFunc: "targetDetection",
65
+ actionActuatorFuncParam: {
66
+ focalLength: 70,
67
+ payloadPositionIndex: 0,
68
+ useGlobalPayloadLensIndex: false,
69
+ payloadLensIndex: ["visable"],
70
+ actionUUID: "test-target-detection",
71
+ customTitle: "巡逻",
72
+ customText: "检测到异常目标",
73
+ modelIndex: 0,
74
+ modelScore: 55,
75
+ alarmInterval: 2,
76
+ useThirdModel: false,
77
+ algorithmType: "visable_ai",
78
+ targetParam: {
79
+ maxThreshold: 10,
80
+ minThreshold: 10,
81
+ useMaxThreshold: false,
82
+ useMinThreshold: true,
83
+ targetType: "person",
84
+ },
85
+ },
86
+ },
87
+ });
88
+
89
+ const folder = plan(input, { lineSpacing: meters(25) }).Folder[0]!;
90
+ const startFuncs = folder.startActionGroup!.action.map((a) => a.actionActuatorFunc);
91
+ const routeFuncs = folder.Placemark[0]!.actionGroup![0]!.action.map(
92
+ (a) => a.actionActuatorFunc,
93
+ );
94
+
95
+ expect(startFuncs).toEqual(["gimbalRotate", "startRecord"]);
96
+ expect(routeFuncs).toEqual(["gimbalRotate", "startRecord", "targetDetection"]);
97
+ expect(JSON.stringify(folder)).not.toContain("stopRecord");
98
+ });
99
+
57
100
  it("centers floor(width / spacing) scan lines", () => {
58
101
  const waylines = plan(template(), { lineSpacing: meters(25) });
59
102
 
@@ -1,4 +1,5 @@
1
1
  import { degrees, meters, type LngLat, type Meters } from "../types/branded.js";
2
+ import type { Action } from "../types/action.js";
2
3
  import { PlannerError } from "../types/errors.js";
3
4
  import type { ExecuteHeightMode } from "../types/enums.js";
4
5
  import type { WaylinesPlacemark } from "../types/placemark.js";
@@ -59,6 +60,18 @@ function gimbalAction(pitch: number) {
59
60
  };
60
61
  }
61
62
 
63
+ function startRecordAction(payloadPositionIndex: number, actionId: number) {
64
+ return {
65
+ actionId,
66
+ actionActuatorFunc: "startRecord" as const,
67
+ actionActuatorFuncParam: {
68
+ payloadPositionIndex,
69
+ useGlobalPayloadLensIndex: true,
70
+ payloadLensIndex: ["visable" as const],
71
+ },
72
+ };
73
+ }
74
+
62
75
  function pathPoints(
63
76
  template: TargetDetectionTemplate,
64
77
  lineSpacingM: number,
@@ -143,6 +156,14 @@ export function planTargetDetection(
143
156
  if (points.length < 2) throw new PlannerError("targetdetection polygon produced no flight path");
144
157
  const lastIndex = points.length - 1;
145
158
  const action = gimbalAction(pitch);
159
+ const payloadPositionIndex = folder.payloadParam?.payloadPositionIndex ?? 0;
160
+ const routeActions: Action[] = [action];
161
+ if (folder.Placemark.recordEnable) {
162
+ routeActions.push(startRecordAction(payloadPositionIndex, routeActions.length));
163
+ }
164
+ if (folder.Placemark.action) {
165
+ routeActions.push({ ...folder.Placemark.action, actionId: routeActions.length });
166
+ }
146
167
 
147
168
  const placemarks: WaylinesPlacemark[] = points.map((point, index) => ({
148
169
  Point: { coordinates: point },
@@ -185,7 +206,7 @@ export function planTargetDetection(
185
206
  actionGroupEndIndex: lastIndex,
186
207
  actionGroupMode: "sequence",
187
208
  actionTrigger: { actionTriggerType: "betweenAdjacentPointsIncludeFirstPoint" },
188
- action: [action],
209
+ action: routeActions,
189
210
  },
190
211
  ],
191
212
  }
@@ -200,7 +221,11 @@ export function planTargetDetection(
200
221
  waylineId: 0,
201
222
  executeHeightMode: executeHeightMode(folder),
202
223
  autoFlightSpeed: folder.autoFlightSpeed,
203
- startActionGroup: { action: [gimbalAction(pitch)] },
224
+ startActionGroup: {
225
+ action: folder.Placemark.recordEnable
226
+ ? [gimbalAction(pitch), startRecordAction(payloadPositionIndex, 1)]
227
+ : [gimbalAction(pitch)],
228
+ },
204
229
  Placemark: placemarks,
205
230
  },
206
231
  ],
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it } from "vite-plus/test";
2
2
  import { degrees, meters, metersPerSecond } from "../types/branded.js";
3
3
  import { DroneEnum, PayloadEnum } from "../types/drone-payload.js";
4
4
  import type { Mapping2dTemplate, Mapping3dTemplate, WaypointTemplate } from "../types/template.js";
@@ -0,0 +1,241 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { selectStripRouteCandidates, type StripRouteCandidate } from "./strip-route-order.js";
3
+ import * as stripOrder from "./strip-route-order.js";
4
+
5
+ function route(patch: Partial<StripRouteCandidate> = {}): StripRouteCandidate {
6
+ return {
7
+ start: [0, 0],
8
+ end: [10, 0],
9
+ entryDirection: [1, 0],
10
+ exitDirection: [1, 0],
11
+ length: 10,
12
+ ...patch,
13
+ };
14
+ }
15
+
16
+ describe("strip region transition ordering", () => {
17
+ it("chooses a slightly longer transfer when it avoids reversing the entry heading", () => {
18
+ const previous = route({ end: [0, 0] });
19
+ const reverse = route({ start: [10, 0], entryDirection: [-1, 0] });
20
+ const aligned = route({ start: [12, 0] });
21
+ expect(selectStripRouteCandidates([[previous], [reverse, aligned]])).toEqual([0, 1]);
22
+ });
23
+
24
+ it("prefers approaching the shared junction over cutting across a bent strip", () => {
25
+ const diagonal = Math.SQRT1_2;
26
+ const previous = route({ end: [0, 0], exitDirection: [diagonal, diagonal] });
27
+ const across = route({ start: [10, 0], entryDirection: [1, 0] });
28
+ const along = route({ start: [0, 12], entryDirection: [0, 1] });
29
+ expect(selectStripRouteCandidates([[previous], [across, along]], undefined, [[0, 10]])).toEqual(
30
+ [0, 1],
31
+ );
32
+ });
33
+
34
+ it("includes the candidate's internal flight length", () => {
35
+ expect(selectStripRouteCandidates([[route({ length: 100 }), route({ length: 10 })]])).toEqual([
36
+ 1,
37
+ ]);
38
+ });
39
+
40
+ it("keeps heading preferences soft when the aligned transfer would be much longer", () => {
41
+ const previous = route({ end: [0, 0] });
42
+ expect(
43
+ selectStripRouteCandidates([
44
+ [previous],
45
+ [route({ start: [10, 0], entryDirection: [-1, 0] }), route({ start: [100, 0] })],
46
+ ]),
47
+ ).toEqual([0, 0]);
48
+ });
49
+
50
+ it("uses the exit and entry headings even when the endpoints coincide", () => {
51
+ const previous = route({ end: [0, 0] });
52
+ expect(
53
+ selectStripRouteCandidates([[previous], [route({ entryDirection: [-1, 0] }), route()]]),
54
+ ).toEqual([0, 1]);
55
+ });
56
+
57
+ it("optimizes the entire sequence instead of taking the locally closest start", () => {
58
+ const first = route({ end: [0, 0] });
59
+ const near = route({ start: [1, 0], end: [100, 0] });
60
+ const connected = route({ start: [2, 0], end: [5, 0] });
61
+ const last = route({ start: [6, 0] });
62
+ expect(selectStripRouteCandidates([[first], [near, connected], [last]])).toEqual([0, 1, 0]);
63
+ });
64
+
65
+ it("uses the takeoff point when selecting the first orientation", () => {
66
+ expect(selectStripRouteCandidates([[route({ start: [100, 0] }), route()]], [0, 0])).toEqual([
67
+ 1,
68
+ ]);
69
+ });
70
+
71
+ it("handles coincident region endpoints without undefined directions", () => {
72
+ const groups = [[route({ end: [0, 0] })], [route(), route()]];
73
+ expect(selectStripRouteCandidates(groups, undefined, [[0, 0]])).toEqual([0, 0]);
74
+ expect(selectStripRouteCandidates([])).toEqual([]);
75
+ });
76
+
77
+ it("includes the final home leg only when the finish action requests it", () => {
78
+ const groups = [[route({ end: [100, 0], length: 100 }), route({ end: [1, 0], length: 101 })]];
79
+ expect(
80
+ selectStripRouteCandidates(groups, [0, 0], [], {
81
+ flightSpeed: 1,
82
+ transitSpeed: 1,
83
+ finishAction: "goHome",
84
+ }),
85
+ ).toEqual([1]);
86
+ for (const finishAction of ["noAction", "autoLand"] as const) {
87
+ expect(
88
+ selectStripRouteCandidates(groups, [0, 0], [], {
89
+ flightSpeed: 1,
90
+ transitSpeed: 1,
91
+ finishAction,
92
+ }),
93
+ ).toEqual([0]);
94
+ }
95
+ });
96
+
97
+ it("uses different survey and transit speeds instead of minimizing distance", () => {
98
+ const groups = [
99
+ [route({ end: [0, 0], length: 200 }), route({ start: [100, 0], end: [200, 0], length: 100 })],
100
+ ];
101
+ expect(
102
+ selectStripRouteCandidates(groups, [0, 0], [], {
103
+ flightSpeed: 1,
104
+ transitSpeed: 10,
105
+ finishAction: "noAction",
106
+ }),
107
+ ).toEqual([1]);
108
+ expect(
109
+ selectStripRouteCandidates(groups, [0, 0], [], {
110
+ flightSpeed: 1,
111
+ transitSpeed: 1,
112
+ finishAction: "noAction",
113
+ }),
114
+ ).toEqual([0]);
115
+ });
116
+
117
+ it("prefers the nearer entry when total flying times differ by at most two seconds", () => {
118
+ expect(
119
+ selectStripRouteCandidates(
120
+ [
121
+ [
122
+ route({ start: [100, 0], end: [200, 0], length: 100 }),
123
+ route({ start: [0, 0], end: [210, 0], length: 210 }),
124
+ ],
125
+ ],
126
+ [0, 0],
127
+ [],
128
+ { flightSpeed: 10, transitSpeed: 10, finishAction: "noAction" },
129
+ ),
130
+ ).toEqual([1]);
131
+ });
132
+
133
+ it("does not sacrifice a substantial time saving just to start closer", () => {
134
+ expect(
135
+ selectStripRouteCandidates(
136
+ [
137
+ [
138
+ route({ start: [100, 0], end: [200, 0], length: 100 }),
139
+ route({ start: [0, 0], end: [250, 0], length: 250 }),
140
+ ],
141
+ ],
142
+ [0, 0],
143
+ [],
144
+ { flightSpeed: 10, transitSpeed: 10, finishAction: "noAction" },
145
+ ),
146
+ ).toEqual([0]);
147
+ });
148
+
149
+ it("retains the nearer first entry after both paths reach the same later candidates", () => {
150
+ const groups = [
151
+ [
152
+ route({
153
+ start: [100, 0],
154
+ end: [0, 0],
155
+ entryDirection: [-1, 0],
156
+ exitDirection: [-1, 0],
157
+ length: 100,
158
+ }),
159
+ route({ end: [0, 0], length: 120 }),
160
+ ],
161
+ [route({ start: [0, 0], end: [100, 0], length: 100 })],
162
+ [route({ start: [100, 0], end: [200, 0], length: 100 })],
163
+ ];
164
+ expect(
165
+ selectStripRouteCandidates(groups, [0, 0], [], {
166
+ flightSpeed: 10,
167
+ transitSpeed: 100,
168
+ finishAction: "goHome",
169
+ }),
170
+ ).toEqual([1, 0, 0]);
171
+ });
172
+
173
+ it("applies the two-second tolerance once to the complete mission, not once per region", () => {
174
+ const groups = [[route({ end: [0, 0], length: 100 })]];
175
+ for (let index = 0; index < 4; index++)
176
+ groups.push([
177
+ route({ end: [0, 0], length: 100, entryDirection: [-1, 0], exitDirection: [-1, 0] }),
178
+ route({ end: [0, 0], length: 101.5 }),
179
+ ]);
180
+ const selected = selectStripRouteCandidates(groups);
181
+ const duration = groups.reduce((sum, group, index) => sum + group[selected[index]!]!.length, 0);
182
+ expect(duration).toBeLessThanOrEqual(502);
183
+ });
184
+
185
+ it("returns to the first waypoint without needing a takeoff reference", () => {
186
+ const groups = [[route({ end: [100, 0], length: 100 }), route({ end: [1, 0], length: 101 })]];
187
+ expect(
188
+ selectStripRouteCandidates(groups, undefined, [], {
189
+ flightSpeed: 1,
190
+ transitSpeed: 1,
191
+ finishAction: "gotoFirstWaypoint",
192
+ }),
193
+ ).toEqual([1]);
194
+ expect(
195
+ selectStripRouteCandidates(groups, undefined, [], {
196
+ flightSpeed: 1,
197
+ transitSpeed: 1,
198
+ finishAction: "goHome",
199
+ }),
200
+ ).toEqual([0]);
201
+ });
202
+
203
+ it("measures survey, ingress, between-region and finish legs with their respective speeds", () => {
204
+ const routes = [
205
+ route({ start: [10, 0], end: [30, 0], length: 20 }),
206
+ route({ start: [50, 0], end: [80, 0], length: 30 }),
207
+ ];
208
+ for (const [finishAction, distance, duration] of [
209
+ ["goHome", 160, 10.5],
210
+ ["noAction", 80, 6.5],
211
+ ["gotoFirstWaypoint", 150, 10],
212
+ ] as const) {
213
+ expect(
214
+ stripOrder.measureStripMission(routes, [0, 0], {
215
+ flightSpeed: 10,
216
+ transitSpeed: 20,
217
+ finishAction,
218
+ }),
219
+ ).toMatchObject({ distance, duration });
220
+ }
221
+ });
222
+
223
+ it("rejects invalid speeds instead of producing non-finite costs", () => {
224
+ for (const speed of [0, -1, NaN, Infinity]) {
225
+ expect(() =>
226
+ selectStripRouteCandidates([[route()]], [0, 0], [], {
227
+ flightSpeed: speed,
228
+ transitSpeed: 10,
229
+ finishAction: "goHome",
230
+ }),
231
+ ).toThrow();
232
+ expect(() =>
233
+ selectStripRouteCandidates([[route()]], [0, 0], [], {
234
+ flightSpeed: 10,
235
+ transitSpeed: speed,
236
+ finishAction: "goHome",
237
+ }),
238
+ ).toThrow();
239
+ }
240
+ });
241
+ });
@@ -0,0 +1,192 @@
1
+ import type { EnuPoint } from "./geometry.js";
2
+ import type { FinishAction } from "../types/enums.js";
3
+ import { PlannerError } from "../types/errors.js";
4
+
5
+ // Secondary route-shape preferences, never added to the estimated flying time.
6
+ // A complete reversal costs 20 preference units; junction detours remain soft.
7
+ const HEADING_COST_M = 10;
8
+ const JUNCTION_DETOUR_WEIGHT = 0.5;
9
+ const NEAR_EQUAL_TIME_S = 2;
10
+ const TIME_EPS_S = 1e-8;
11
+
12
+ const distance = (a: EnuPoint, b: EnuPoint) => Math.hypot(a[0] - b[0], a[1] - b[1]);
13
+ const headingChange = (a: EnuPoint, b: EnuPoint) =>
14
+ 1 - Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1]));
15
+
16
+ export interface StripRouteCandidate {
17
+ start: EnuPoint;
18
+ end: EnuPoint;
19
+ entryDirection: EnuPoint;
20
+ exitDirection: EnuPoint;
21
+ length: number;
22
+ }
23
+
24
+ export interface StripRouteTiming {
25
+ flightSpeed: number;
26
+ transitSpeed: number;
27
+ finishAction: FinishAction;
28
+ }
29
+
30
+ const DEFAULT_TIMING: StripRouteTiming = {
31
+ flightSpeed: 1,
32
+ transitSpeed: 1,
33
+ finishAction: "noAction",
34
+ };
35
+
36
+ function validateTiming(timing: StripRouteTiming): void {
37
+ if (
38
+ ![timing.flightSpeed, timing.transitSpeed].every((speed) => Number.isFinite(speed) && speed > 0)
39
+ )
40
+ throw new PlannerError(
41
+ "mappingStrip flight and transit speeds must be finite and greater than zero",
42
+ );
43
+ }
44
+
45
+ function finishDistance(
46
+ end: EnuPoint,
47
+ first: EnuPoint,
48
+ home: EnuPoint | undefined,
49
+ finish: FinishAction,
50
+ ): number {
51
+ const target = finish === "goHome" ? home : finish === "gotoFirstWaypoint" ? first : undefined;
52
+ return target ? distance(end, target) : 0;
53
+ }
54
+
55
+ /** Horizontal-flight estimate; vertical motion, wind and acceleration are not modeled. */
56
+ export function measureStripMission(
57
+ routes: readonly Pick<StripRouteCandidate, "start" | "end" | "length">[],
58
+ home: EnuPoint | undefined,
59
+ timing: StripRouteTiming,
60
+ ): { distance: number; duration: number } {
61
+ validateTiming(timing);
62
+ if (routes.length === 0) return { distance: 0, duration: 0 };
63
+ const first = routes[0]!.start;
64
+ const workLength = routes.reduce((sum, route) => sum + route.length, 0);
65
+ let transitLength = home ? distance(home, first) : 0;
66
+ for (let index = 1; index < routes.length; index++)
67
+ transitLength += distance(routes[index - 1]!.end, routes[index]!.start);
68
+ transitLength += finishDistance(routes.at(-1)!.end, first, home, timing.finishAction);
69
+ return {
70
+ distance: workLength + transitLength,
71
+ duration: workLength / timing.flightSpeed + transitLength / timing.transitSpeed,
72
+ };
73
+ }
74
+
75
+ function transitionPreference(
76
+ previous: StripRouteCandidate,
77
+ next: StripRouteCandidate,
78
+ junction?: EnuPoint,
79
+ ): number {
80
+ const length = distance(previous.end, next.start);
81
+ const direction: EnuPoint | undefined =
82
+ length > 0.05
83
+ ? [(next.start[0] - previous.end[0]) / length, (next.start[1] - previous.end[1]) / length]
84
+ : undefined;
85
+ const turn = direction
86
+ ? headingChange(previous.exitDirection, direction) +
87
+ headingChange(direction, next.entryDirection)
88
+ : headingChange(previous.exitDirection, next.entryDirection);
89
+ const detour = junction
90
+ ? Math.max(0, distance(previous.end, junction) + distance(junction, next.start) - length)
91
+ : 0;
92
+ return HEADING_COST_M * turn + JUNCTION_DETOUR_WEIGHT * detour;
93
+ }
94
+
95
+ interface FinalChoice {
96
+ lastIndex: number;
97
+ time: number;
98
+ preference: number;
99
+ ingress: number;
100
+ back: number[][];
101
+ }
102
+
103
+ /** Optimize mission time, then favor nearer entries within a two-second total budget. */
104
+ export function selectStripRouteCandidates(
105
+ groups: readonly (readonly StripRouteCandidate[])[],
106
+ home?: EnuPoint,
107
+ junctions: readonly EnuPoint[] = [],
108
+ timing: StripRouteTiming = DEFAULT_TIMING,
109
+ ): number[] {
110
+ if (groups.length === 0) return [];
111
+ validateTiming(timing);
112
+ if (groups.some((group) => group.length === 0))
113
+ throw new PlannerError("mappingStrip region has no route candidates");
114
+ const finals: FinalChoice[] = [];
115
+
116
+ // Keep each possible first entry separate. Otherwise a local near-equal-time
117
+ // tie can discard the globally fastest path or accumulate a tolerance per cut.
118
+ groups[0]!.forEach((first, firstIndex) => {
119
+ const ingress = home ? distance(home, first.start) : 0;
120
+ let times = groups[0]!.map((candidate, index) =>
121
+ index === firstIndex
122
+ ? candidate.length / timing.flightSpeed + ingress / timing.transitSpeed
123
+ : Infinity,
124
+ );
125
+ let preferences = groups[0]!.map((_, index) => (index === firstIndex ? 0 : Infinity));
126
+ const back = [groups[0]!.map(() => -1)];
127
+ for (let index = 1; index < groups.length; index++) {
128
+ const parents: number[] = [];
129
+ const nextPreferences: number[] = [];
130
+ const nextTimes = groups[index]!.map((candidate) => {
131
+ let bestTime = Infinity;
132
+ let bestPreference = Infinity;
133
+ let parent = 0;
134
+ groups[index - 1]!.forEach((previous, previousIndex) => {
135
+ const time =
136
+ times[previousIndex]! +
137
+ candidate.length / timing.flightSpeed +
138
+ distance(previous.end, candidate.start) / timing.transitSpeed;
139
+ const preference =
140
+ preferences[previousIndex]! +
141
+ transitionPreference(previous, candidate, junctions[index - 1]);
142
+ if (
143
+ time < bestTime - TIME_EPS_S ||
144
+ (Math.abs(time - bestTime) <= TIME_EPS_S && preference < bestPreference)
145
+ ) {
146
+ bestTime = time;
147
+ bestPreference = preference;
148
+ parent = previousIndex;
149
+ }
150
+ });
151
+ parents.push(parent);
152
+ nextPreferences.push(bestPreference);
153
+ return bestTime;
154
+ });
155
+ times = nextTimes;
156
+ preferences = nextPreferences;
157
+ back.push(parents);
158
+ }
159
+ groups.at(-1)!.forEach((last, lastIndex) => {
160
+ if (!Number.isFinite(times[lastIndex])) return;
161
+ finals.push({
162
+ lastIndex,
163
+ ingress,
164
+ back,
165
+ time:
166
+ times[lastIndex]! +
167
+ finishDistance(last.end, first.start, home, timing.finishAction) / timing.transitSpeed,
168
+ preference: preferences[lastIndex]!,
169
+ });
170
+ });
171
+ });
172
+ const fastest = Math.min(...finals.map((choice) => choice.time));
173
+ const close = finals.filter((choice) => choice.time <= fastest + NEAR_EQUAL_TIME_S + TIME_EPS_S);
174
+ const chosen = close.reduce((best, candidate) => {
175
+ if (candidate.ingress < best.ingress - 0.05) return candidate;
176
+ if (Math.abs(candidate.ingress - best.ingress) > 0.05) return best;
177
+ if (candidate.preference < best.preference - TIME_EPS_S) return candidate;
178
+ if (
179
+ Math.abs(candidate.preference - best.preference) <= TIME_EPS_S &&
180
+ candidate.time < best.time
181
+ )
182
+ return candidate;
183
+ return best;
184
+ });
185
+ let selected = chosen.lastIndex;
186
+ const result = groups.map(() => 0);
187
+ for (let index = groups.length - 1; index >= 0; index--) {
188
+ result[index] = selected;
189
+ selected = chosen.back[index]![selected]!;
190
+ }
191
+ return result;
192
+ }
@@ -34,6 +34,7 @@ export interface ActionTrigger {
34
34
 
35
35
  /** Discriminated by `actionActuatorFunc`. */
36
36
  export type Action =
37
+ | TargetDetectionAction
37
38
  | TakePhotoAction
38
39
  | StartRecordAction
39
40
  | StopRecordAction
@@ -64,6 +65,31 @@ interface ActionBase<F extends string, P> {
64
65
  actionActuatorFuncParam: P;
65
66
  }
66
67
 
68
+ export interface TargetDetectionParam {
69
+ focalLength?: number;
70
+ payloadPositionIndex: number;
71
+ useGlobalPayloadLensIndex: boolean;
72
+ payloadLensIndex: LensType[];
73
+ actionUUID: string;
74
+ irGainMode?: string;
75
+ irColorPalette?: string;
76
+ customTitle: string;
77
+ customText: string;
78
+ modelIndex: number;
79
+ modelScore: number;
80
+ alarmInterval: number;
81
+ useThirdModel: boolean;
82
+ algorithmType: string;
83
+ targetParam: {
84
+ maxThreshold: number;
85
+ minThreshold: number;
86
+ useMaxThreshold: boolean;
87
+ useMinThreshold: boolean;
88
+ targetType: string;
89
+ };
90
+ }
91
+ export type TargetDetectionAction = ActionBase<"targetDetection", TargetDetectionParam>;
92
+
67
93
  // ─── Action params (one type per actionActuatorFunc) ─────────────────────────
68
94
 
69
95
  export interface TakePhotoParam {
@@ -15,6 +15,8 @@ export interface PayloadInfo {
15
15
  */
16
16
  payloadSubEnumValue?: number;
17
17
  payloadPositionIndex: number;
18
+ /** FlightHub 2 patrol-camera selection, in 35mm-equivalent millimeters. */
19
+ equivalentFocalLength?: number;
18
20
  }
19
21
 
20
22
  /** Aircraft `{ droneEnumValue, droneSubEnumValue }` tuples. */
@@ -73,6 +73,7 @@ export type ActionTriggerType =
73
73
  | "multipleDistance";
74
74
 
75
75
  export type ActionActuatorFunc =
76
+ | "targetDetection"
76
77
  | "takePhoto"
77
78
  | "startRecord"
78
79
  | "stopRecord"
@@ -1,5 +1,5 @@
1
1
  import type { Degrees, LngLat, Meters, MetersPerSecond } from "./branded.js";
2
- import type { ActionGroup } from "./action.js";
2
+ import type { ActionGroup, TargetDetectionAction } from "./action.js";
3
3
  import type { ExecuteHeightMode, ShootType } from "./enums.js";
4
4
  import type {
5
5
  MappingHeadingParam,
@@ -127,6 +127,9 @@ export interface TargetDetectionPlacemark {
127
127
  mappingHeadingParam: MappingHeadingParam;
128
128
  gimbalPitchAngle: Degrees;
129
129
  recordEnable: boolean;
130
+ /** FlightHub 2 always emits this action for patrol-area routes. */
131
+ targetDetectionActionEnable?: boolean;
132
+ action?: TargetDetectionAction;
130
133
  }
131
134
 
132
135
  /** `Placemark` inside a `mappingStrip` Folder — uses a LineString; params live here. */
package/vite.config.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { defineConfig } from "vite-plus";
2
2
 
3
3
  export default defineConfig({
4
+ test: {
5
+ // Vitest v4 compatibility: preserve mock call history.
6
+ // Remove after tests no longer rely on calls from setup or earlier tests.
7
+ // https://viteplus.dev/guide/vitest-v5#remove-unneeded-compatibility-settings
8
+ // https://vitest.dev/guide/migration/#clearmocks-is-enabled-by-default
9
+ clearMocks: false,
10
+ },
4
11
  pack: {
5
12
  entry: ["src/index.ts"],
6
13
  format: "esm",