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 retryWhileConflict,
16 stableEquals,
17 syncFleetWiseTags,
18 toFleetWiseTagList,
19} from "./internal.ts";
20
21export interface DecoderManifestProps {
22 /**
23 * Name of the decoder manifest. Must be 1-100 characters of
24 * `[a-zA-Z0-9:_-]`. If omitted, a deterministic physical name is
25 * generated. Changing the name replaces the manifest.
26 */
27 decoderManifestName?: string;
28 /**
29 * ARN of the {@link ModelManifest} the decoder manifest decodes signals
30 * for. Changing the model manifest replaces the decoder manifest.
31 */
32 modelManifestArn: string;
33 /**
34 * Human-readable description of the decoder manifest.
35 */
36 description?: string;
37 /**
38 * Network interfaces (CAN, OBD, vehicle middleware or custom decoding)
39 * the signal decoders reference, keyed by `interfaceId`. Updated in
40 * place via add/update/remove deltas (only while the manifest is in
41 * `DRAFT` status).
42 */
43 networkInterfaces?: iotfleetwise.NetworkInterface[];
44 /**
45 * Decoding rules mapping model-manifest signals onto network-interface
46 * messages, keyed by `fullyQualifiedName`. Updated in place via
47 * add/update/remove deltas (only while the manifest is in `DRAFT`
48 * status).
49 */
50 signalDecoders?: iotfleetwise.SignalDecoder[];
51 /**
52 * Use `"CUSTOM_DECODING"` to default any unmapped model-manifest signal
53 * to a custom decoding signal.
54 */
55 defaultForUnmappedSignals?: iotfleetwise.DefaultForUnmappedSignalsType;
56 /**
57 * Status of the manifest. Vehicles can only be created from an `ACTIVE`
58 * manifest; decoder/interface changes require `DRAFT`.
59 * @default "DRAFT"
60 */
61 status?: "ACTIVE" | "DRAFT";
62 /**
63 * User-defined tags for the decoder manifest.
64 */
65 tags?: Record<string, string>;
66}
67
68export interface DecoderManifest extends Resource<
69 "AWS.IoTFleetWise.DecoderManifest",
70 DecoderManifestProps,
71 {
72 /** The name of the decoder manifest. */
73 decoderManifestName: string;
74 /** The ARN of the decoder manifest. */
75 decoderManifestArn: string;
76 /** The current status of the manifest (`ACTIVE`, `DRAFT`, ...). */
77 status: string;
78 /** The model manifest the decoders map to. */
79 modelManifestArn: string | undefined;
80 },
81 never,
82 Providers
83> {}
84
85/**
86 * An AWS IoT FleetWise decoder manifest — the decoding rules (network
87 * interfaces + signal decoders) that turn raw bus data from a vehicle
88 * modeled by a {@link ModelManifest} into standardized signals.
89 *
90 * A manifest is created in `DRAFT` status; set `status: "ACTIVE"` to make
91 * it usable by vehicles. AWS IoT FleetWise is allowlist-gated and offered
92 * in `us-east-1`/`eu-central-1` only.
93 * ### Creating a Decoder Manifest
94 * **Example:** OBD Decoder for a Speed Signal
95 * ```typescript
96 * const decoder = yield* DecoderManifest("SedanDecoder", {
97 * modelManifestArn: model.modelManifestArn,
98 * networkInterfaces: [
99 * {
100 * interfaceId: "obd0",
101 * type: "OBD_INTERFACE",
102 * obdInterface: { name: "obd", requestMessageId: 2015 },
103 * },
104 * ],
105 * signalDecoders: [
106 * {
107 * fullyQualifiedName: "Vehicle.Speed",
108 * type: "OBD_SIGNAL",
109 * interfaceId: "obd0",
110 * obdSignal: {
111 * pidResponseLength: 1,
112 * serviceMode: 1,
113 * pid: 13,
114 * scaling: 1,
115 * offset: 0,
116 * startByte: 0,
117 * byteLength: 1,
118 * },
119 * },
120 * ],
121 * status: "ACTIVE",
122 * });
123 * ```
124 *
125 * @resource
126 */
127export const DecoderManifest = Resource<DecoderManifest>(
128 "AWS.IoTFleetWise.DecoderManifest",
129);
130
131export const DecoderManifestProvider = () =>
132 Provider.effect(
133 DecoderManifest,
134 Effect.gen(function* () {
135 const toName = (id: string, props: { decoderManifestName?: string }) =>
136 props.decoderManifestName
137 ? Effect.succeed(props.decoderManifestName)
138 : createPhysicalName({ id, maxLength: 100 });
139
140 const readManifest = Effect.fn(function* (name: string) {
141 return yield* iotfleetwise.getDecoderManifest({ name }).pipe(
142 inFleetWiseRegion,
143 Effect.catchTag("ResourceNotFoundException", () =>
144 Effect.succeed(undefined),
145 ),
146 );
147 });
148
149 const observeAfterWrite = Effect.fn(function* (name: string) {
150 return yield* readManifest(name).pipe(
151 Effect.flatMap((manifest) =>
152 manifest === undefined
153 ? Effect.fail(new Error(`Decoder manifest '${name}' not found`))
154 : Effect.succeed(manifest),
155 ),
156 retryObservation,
157 );
158 });
159
160 const readInterfaces = Effect.fn(function* (name: string) {
161 return yield* iotfleetwise.listDecoderManifestNetworkInterfaces
162 .items({ name })
163 .pipe(
164 Stream.runCollect,
165 Effect.map((chunk) => Array.from(chunk)),
166 inFleetWiseRegion,
167 );
168 });
169
170 const readDecoders = Effect.fn(function* (name: string) {
171 return yield* iotfleetwise.listDecoderManifestSignals
172 .items({ name })
173 .pipe(
174 Stream.runCollect,
175 Effect.map((chunk) => Array.from(chunk)),
176 inFleetWiseRegion,
177 );
178 });
179
180 const toAttrs = (manifest: iotfleetwise.GetDecoderManifestResponse) => ({
181 decoderManifestName: manifest.name,
182 decoderManifestArn: manifest.arn,
183 status: manifest.status ?? "DRAFT",
184 modelManifestArn: manifest.modelManifestArn,
185 });
186
187 return {
188 stables: ["decoderManifestName", "decoderManifestArn"],
189
190 diff: Effect.fn(function* ({ id, olds, news }) {
191 if (!isResolved(news)) return undefined;
192 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
193 return { action: "replace" } as const;
194 }
195 // The update API cannot change the model manifest.
196 if (news.modelManifestArn !== olds.modelManifestArn) {
197 return { action: "replace" } as const;
198 }
199 }),
200
201 read: Effect.fn(function* ({ id, olds, output }) {
202 const name =
203 output?.decoderManifestName ?? (yield* toName(id, olds ?? {}));
204 const found = yield* readManifest(name);
205 if (found === undefined) return undefined;
206 const attrs = toAttrs(found);
207 const tags = yield* readFleetWiseTags(found.arn);
208 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
209 }),
210
211 reconcile: Effect.fn(function* ({ id, news, output, session }) {
212 const name = output?.decoderManifestName ?? (yield* toName(id, news));
213 const internalTags = yield* createInternalTags(id);
214 const desiredTags = { ...internalTags, ...news.tags };
215 const desiredStatus = news.status ?? "DRAFT";
216 const desiredInterfaces = news.networkInterfaces ?? [];
217 const desiredDecoders = news.signalDecoders ?? [];
218
219 // 1. Observe — cloud state is authoritative.
220 let observed = yield* readManifest(name);
221
222 // 2. Ensure — create if missing; tolerate the AlreadyExists race.
223 if (observed === undefined) {
224 yield* iotfleetwise
225 .createDecoderManifest({
226 name,
227 modelManifestArn: news.modelManifestArn,
228 description: news.description,
229 networkInterfaces:
230 desiredInterfaces.length > 0 ? desiredInterfaces : undefined,
231 signalDecoders:
232 desiredDecoders.length > 0 ? desiredDecoders : undefined,
233 defaultForUnmappedSignals: news.defaultForUnmappedSignals,
234 tags: toFleetWiseTagList(desiredTags),
235 })
236 .pipe(
237 inFleetWiseRegion,
238 Effect.catchTag("ConflictException", () => Effect.void),
239 );
240 observed = yield* observeAfterWrite(name);
241 }
242
243 // 3. Sync interfaces + decoders + description — diff OBSERVED
244 // lists against desired, keyed by interfaceId /
245 // fullyQualifiedName. Structural changes require DRAFT status,
246 // so they run before any DRAFT -> ACTIVE transition.
247 const observedInterfaces = yield* readInterfaces(name);
248 const observedDecoders = yield* readDecoders(name);
249 const observedInterfaceById = new Map(
250 observedInterfaces.map((i) => [i.interfaceId, i]),
251 );
252 const desiredInterfaceById = new Map(
253 desiredInterfaces.map((i) => [i.interfaceId, i]),
254 );
255 const observedDecoderByFqn = new Map(
256 observedDecoders.map((d) => [d.fullyQualifiedName, d]),
257 );
258 const desiredDecoderByFqn = new Map(
259 desiredDecoders.map((d) => [d.fullyQualifiedName, d]),
260 );
261
262 const interfacesToAdd = desiredInterfaces.filter(
263 (i) => !observedInterfaceById.has(i.interfaceId),
264 );
265 const interfacesToUpdate = desiredInterfaces.filter((i) => {
266 const current = observedInterfaceById.get(i.interfaceId);
267 return current !== undefined && !stableEquals(current, i);
268 });
269 const interfacesToRemove = observedInterfaces
270 .map((i) => i.interfaceId)
271 .filter((id_) => !desiredInterfaceById.has(id_));
272 const decodersToAdd = desiredDecoders.filter(
273 (d) => !observedDecoderByFqn.has(d.fullyQualifiedName),
274 );
275 const decodersToUpdate = desiredDecoders.filter((d) => {
276 const current = observedDecoderByFqn.get(d.fullyQualifiedName);
277 return current !== undefined && !stableEquals(current, d);
278 });
279 const decodersToRemove = observedDecoders
280 .map((d) => d.fullyQualifiedName)
281 .filter((fqn) => !desiredDecoderByFqn.has(fqn));
282 const descriptionChanged =
283 news.description !== undefined &&
284 news.description !== observed.description;
285
286 if (
287 interfacesToAdd.length > 0 ||
288 interfacesToUpdate.length > 0 ||
289 interfacesToRemove.length > 0 ||
290 decodersToAdd.length > 0 ||
291 decodersToUpdate.length > 0 ||
292 decodersToRemove.length > 0 ||
293 descriptionChanged
294 ) {
295 yield* iotfleetwise
296 .updateDecoderManifest({
297 name,
298 description: descriptionChanged ? news.description : undefined,
299 networkInterfacesToAdd:
300 interfacesToAdd.length > 0 ? interfacesToAdd : undefined,
301 networkInterfacesToUpdate:
302 interfacesToUpdate.length > 0
303 ? interfacesToUpdate
304 : undefined,
305 networkInterfacesToRemove:
306 interfacesToRemove.length > 0
307 ? interfacesToRemove
308 : undefined,
309 signalDecodersToAdd:
310 decodersToAdd.length > 0 ? decodersToAdd : undefined,
311 signalDecodersToUpdate:
312 decodersToUpdate.length > 0 ? decodersToUpdate : undefined,
313 signalDecodersToRemove:
314 decodersToRemove.length > 0 ? decodersToRemove : undefined,
315 defaultForUnmappedSignals: news.defaultForUnmappedSignals,
316 })
317 .pipe(inFleetWiseRegion);
318 observed = yield* observeAfterWrite(name);
319 }
320
321 // 3b. Sync status (e.g. activate a draft).
322 if ((observed.status ?? "DRAFT") !== desiredStatus) {
323 yield* iotfleetwise
324 .updateDecoderManifest({ name, status: desiredStatus })
325 .pipe(inFleetWiseRegion);
326 observed = yield* observeAfterWrite(name);
327 }
328
329 // 3c. Sync tags against OBSERVED cloud tags.
330 yield* syncFleetWiseTags(observed.arn, desiredTags);
331
332 yield* session.note(name);
333 return toAttrs(observed);
334 }),
335
336 delete: Effect.fn(function* ({ output }) {
337 // Idempotent: deleting a missing manifest succeeds. Vehicles
338 // still detaching surface as ConflictException — retry through
339 // the window (bounded).
340 yield* iotfleetwise
341 .deleteDecoderManifest({ name: output.decoderManifestName })
342 .pipe(
343 inFleetWiseRegion,
344 retryWhileConflict,
345 Effect.catchTag("ConflictException", () => Effect.void),
346 );
347 }),
348
349 list: () =>
350 iotfleetwise.listDecoderManifests.items({}).pipe(
351 Stream.runCollect,
352 Effect.map((chunk) =>
353 Array.from(chunk).flatMap((summary) =>
354 summary.name !== undefined && summary.arn !== undefined
355 ? [
356 {
357 decoderManifestName: summary.name,
358 decoderManifestArn: summary.arn,
359 status: summary.status ?? "DRAFT",
360 modelManifestArn: summary.modelManifestArn,
361 },
362 ]
363 : [],
364 ),
365 ),
366 inFleetWiseRegion,
367 ),
368 };
369 }),
370 );