1import * as iotfleetwise from "@distilled.cloud/aws/iotfleetwise";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 inFleetWiseRegion,
13 readFleetWiseTags,
14 retryObservation,
15 stableEquals,
16 syncFleetWiseTags,
17 toFleetWiseTagList,
18} from "./internal.ts";
19
20export interface VehicleProps {
21 /**
22 * Name of the vehicle. Must be 1-100 characters of `[a-zA-Z0-9:_-]` and
23 * match the corresponding AWS IoT thing name. If omitted, a
24 * deterministic physical name is generated. Changing the name replaces
25 * the vehicle.
26 */
27 vehicleName?: string;
28 /**
29 * ARN of an `ACTIVE` {@link ModelManifest} the vehicle is modeled by.
30 * Updated in place.
31 */
32 modelManifestArn: string;
33 /**
34 * ARN of an `ACTIVE` {@link DecoderManifest} associated with the model
35 * manifest. Updated in place.
36 */
37 decoderManifestArn: string;
38 /**
39 * Static attribute values (key/value strings) for attributes defined in
40 * the model manifest, e.g. `{ "Vehicle.VIN": "1HGBH..." }`. Synced in
41 * place with `attributeUpdateMode: "Overwrite"`.
42 */
43 attributes?: Record<string, string>;
44 /**
45 * Whether to create a new AWS IoT thing for the vehicle
46 * (`"CreateIotThing"`) or validate an existing thing
47 * (`"ValidateIotThingExists"`). Applied at creation only; changing it
48 * replaces the vehicle.
49 * @default "CreateIotThing"
50 */
51 associationBehavior?: iotfleetwise.VehicleAssociationBehavior;
52 /**
53 * State templates associated with the vehicle — each pairs a
54 * {@link StateTemplate} identifier (name or ARN) with an update strategy
55 * (`{ onChange: {} }` or `{ periodic: { stateTemplateUpdateRate } }`).
56 * Synced in place via add/remove/update deltas.
57 */
58 stateTemplates?: iotfleetwise.StateTemplateAssociation[];
59 /**
60 * User-defined tags for the vehicle.
61 */
62 tags?: Record<string, string>;
63}
64
65export interface Vehicle extends Resource<
66 "AWS.IoTFleetWise.Vehicle",
67 VehicleProps,
68 {
69 /** The name of the vehicle (also the backing IoT thing name). */
70 vehicleName: string;
71 /** The ARN of the vehicle. */
72 vehicleArn: string;
73 /** The model manifest the vehicle conforms to. */
74 modelManifestArn: string;
75 /** The decoder manifest the vehicle uses. */
76 decoderManifestArn: string;
77 /** The static attributes stored on the vehicle. */
78 attributes: Record<string, string>;
79 },
80 never,
81 Providers
82> {}
83
84/**
85 * An AWS IoT FleetWise vehicle — the digital twin of a physical vehicle,
86 * created from an `ACTIVE` {@link ModelManifest} and
87 * {@link DecoderManifest} pair and backed by an AWS IoT thing.
88 * ### Creating a Vehicle
89 * **Example:** Vehicle with an Auto-Created IoT Thing
90 * ```typescript
91 * const vehicle = yield* Vehicle("TestVehicle", {
92 * modelManifestArn: model.modelManifestArn,
93 * decoderManifestArn: decoder.decoderManifestArn,
94 * });
95 * ```
96 *
97 * **Example:** Vehicle with Attributes
98 * ```typescript
99 * const vehicle = yield* Vehicle("TestVehicle", {
100 * modelManifestArn: model.modelManifestArn,
101 * decoderManifestArn: decoder.decoderManifestArn,
102 * attributes: { "Vehicle.VIN": "1HGBH41JXMN109186" },
103 * tags: { plant: "fremont" },
104 * });
105 * ```
106 *
107 * @resource
108 */
109export const Vehicle = Resource<Vehicle>("AWS.IoTFleetWise.Vehicle");
110
111const toAttributeRecord = (
112 attributes: { [key: string]: string | undefined } | undefined,
113): Record<string, string> =>
114 Object.fromEntries(
115 Object.entries(attributes ?? {}).flatMap(([key, value]) =>
116 value !== undefined ? [[key, value]] : [],
117 ),
118 );
119
120export const VehicleProvider = () =>
121 Provider.effect(
122 Vehicle,
123 Effect.gen(function* () {
124 const toName = (id: string, props: { vehicleName?: string }) =>
125 props.vehicleName
126 ? Effect.succeed(props.vehicleName)
127 : createPhysicalName({ id, maxLength: 100 });
128
129 const readVehicle = Effect.fn(function* (vehicleName: string) {
130 return yield* iotfleetwise.getVehicle({ vehicleName }).pipe(
131 inFleetWiseRegion,
132 Effect.catchTag("ResourceNotFoundException", () =>
133 Effect.succeed(undefined),
134 ),
135 );
136 });
137
138 const toAttrs = Effect.fn(function* (
139 vehicle: iotfleetwise.GetVehicleResponse,
140 ) {
141 if (
142 vehicle.vehicleName === undefined ||
143 vehicle.arn === undefined ||
144 vehicle.modelManifestArn === undefined ||
145 vehicle.decoderManifestArn === undefined
146 ) {
147 return yield* Effect.fail(
148 new Error(
149 `Vehicle '${vehicle.vehicleName}' is missing required fields`,
150 ),
151 );
152 }
153 return {
154 vehicleName: vehicle.vehicleName,
155 vehicleArn: vehicle.arn,
156 modelManifestArn: vehicle.modelManifestArn,
157 decoderManifestArn: vehicle.decoderManifestArn,
158 attributes: toAttributeRecord(vehicle.attributes),
159 };
160 });
161
162 return {
163 stables: ["vehicleName", "vehicleArn"],
164
165 diff: Effect.fn(function* ({ id, olds, news }) {
166 if (!isResolved(news)) return undefined;
167 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
168 return { action: "replace" } as const;
169 }
170 // The IoT-thing association is decided at creation time.
171 if (
172 (news.associationBehavior ?? "CreateIotThing") !==
173 (olds.associationBehavior ?? "CreateIotThing")
174 ) {
175 return { action: "replace" } as const;
176 }
177 }),
178
179 read: Effect.fn(function* ({ id, olds, output }) {
180 const name = output?.vehicleName ?? (yield* toName(id, olds ?? {}));
181 const found = yield* readVehicle(name);
182 if (found?.arn === undefined) return undefined;
183 const attrs = yield* toAttrs(found);
184 const tags = yield* readFleetWiseTags(found.arn);
185 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
186 }),
187
188 reconcile: Effect.fn(function* ({ id, news, output, session }) {
189 const name = output?.vehicleName ?? (yield* toName(id, news));
190 const internalTags = yield* createInternalTags(id);
191 const desiredTags = { ...internalTags, ...news.tags };
192
193 // 1. Observe — cloud state is authoritative.
194 let observed = yield* readVehicle(name);
195
196 // 2. Ensure — create if missing; tolerate the AlreadyExists race.
197 if (observed === undefined) {
198 yield* iotfleetwise
199 .createVehicle({
200 vehicleName: name,
201 modelManifestArn: news.modelManifestArn,
202 decoderManifestArn: news.decoderManifestArn,
203 attributes: news.attributes,
204 associationBehavior:
205 news.associationBehavior ?? "CreateIotThing",
206 stateTemplates: news.stateTemplates,
207 tags: toFleetWiseTagList(desiredTags),
208 })
209 .pipe(
210 inFleetWiseRegion,
211 Effect.catchTag("ConflictException", () => Effect.void),
212 );
213 observed = yield* readVehicle(name).pipe(
214 Effect.flatMap((vehicle) =>
215 vehicle === undefined
216 ? Effect.fail(new Error(`Vehicle '${name}' not found`))
217 : Effect.succeed(vehicle),
218 ),
219 retryObservation,
220 );
221 }
222
223 // 3. Sync manifests + attributes — apply only the observed delta.
224 const manifestChanged =
225 observed.modelManifestArn !== news.modelManifestArn ||
226 observed.decoderManifestArn !== news.decoderManifestArn;
227 const attributesChanged =
228 news.attributes !== undefined &&
229 !stableEquals(
230 toAttributeRecord(observed.attributes),
231 news.attributes,
232 );
233 if (manifestChanged || attributesChanged) {
234 yield* iotfleetwise
235 .updateVehicle({
236 vehicleName: name,
237 modelManifestArn: manifestChanged
238 ? news.modelManifestArn
239 : undefined,
240 decoderManifestArn: manifestChanged
241 ? news.decoderManifestArn
242 : undefined,
243 attributes: attributesChanged ? news.attributes : undefined,
244 attributeUpdateMode: attributesChanged
245 ? "Overwrite"
246 : undefined,
247 })
248 .pipe(inFleetWiseRegion);
249 observed = yield* readVehicle(name).pipe(
250 Effect.flatMap((vehicle) =>
251 vehicle === undefined
252 ? Effect.fail(new Error(`Vehicle '${name}' not found`))
253 : Effect.succeed(vehicle),
254 ),
255 retryObservation,
256 );
257 }
258
259 // 3b. Sync state-template associations against OBSERVED state.
260 // Associations are keyed by identifier; strategy changes are
261 // applied via stateTemplatesToUpdate. Identifiers are matched
262 // exactly (pass the same name-or-ARN form consistently).
263 if (news.stateTemplates !== undefined) {
264 const desired = news.stateTemplates;
265 const observedAssociations = observed.stateTemplates ?? [];
266 const observedByIdentifier = new Map(
267 observedAssociations.map((a) => [a.identifier, a]),
268 );
269 const desiredIdentifiers = new Set(
270 desired.map((a) => a.identifier),
271 );
272 const templatesToAdd = desired.filter(
273 (a) => !observedByIdentifier.has(a.identifier),
274 );
275 const templatesToUpdate = desired.filter((a) => {
276 const current = observedByIdentifier.get(a.identifier);
277 return (
278 current !== undefined &&
279 !stableEquals(
280 current.stateTemplateUpdateStrategy,
281 a.stateTemplateUpdateStrategy,
282 )
283 );
284 });
285 const templatesToRemove = observedAssociations
286 .filter((a) => !desiredIdentifiers.has(a.identifier))
287 .map((a) => a.identifier);
288 if (
289 templatesToAdd.length > 0 ||
290 templatesToUpdate.length > 0 ||
291 templatesToRemove.length > 0
292 ) {
293 yield* iotfleetwise
294 .updateVehicle({
295 vehicleName: name,
296 stateTemplatesToAdd:
297 templatesToAdd.length > 0 ? templatesToAdd : undefined,
298 stateTemplatesToUpdate:
299 templatesToUpdate.length > 0
300 ? templatesToUpdate
301 : undefined,
302 stateTemplatesToRemove:
303 templatesToRemove.length > 0
304 ? templatesToRemove
305 : undefined,
306 })
307 .pipe(inFleetWiseRegion);
308 observed = yield* readVehicle(name).pipe(
309 Effect.flatMap((vehicle) =>
310 vehicle === undefined
311 ? Effect.fail(new Error(`Vehicle '${name}' not found`))
312 : Effect.succeed(vehicle),
313 ),
314 retryObservation,
315 );
316 }
317 }
318
319 // 3c. Sync tags against OBSERVED cloud tags.
320 const arn = observed.arn;
321 if (arn !== undefined) {
322 yield* syncFleetWiseTags(arn, desiredTags);
323 }
324
325 yield* session.note(name);
326 return yield* toAttrs(observed);
327 }),
328
329 delete: Effect.fn(function* ({ output }) {
330 // Idempotent: deleting a missing vehicle succeeds. The backing
331 // IoT thing (if auto-created) is left in place, matching AWS
332 // behavior.
333 yield* iotfleetwise
334 .deleteVehicle({ vehicleName: output.vehicleName })
335 .pipe(inFleetWiseRegion);
336 }),
337
338 list: () =>
339 iotfleetwise.listVehicles.items({}).pipe(
340 Stream.runCollect,
341 Effect.map((chunk) =>
342 Array.from(chunk).map((summary) => ({
343 vehicleName: summary.vehicleName,
344 vehicleArn: summary.arn,
345 modelManifestArn: summary.modelManifestArn,
346 decoderManifestArn: summary.decoderManifestArn,
347 attributes: toAttributeRecord(summary.attributes),
348 })),
349 ),
350 inFleetWiseRegion,
351 ),
352 };
353 }),
354 );