1import * as iotw from "@distilled.cloud/aws/iot-wireless";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import {
11 createInternalTags,
12 createTagsList,
13 hasAlchemyTags,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16import {
17 readIotWirelessTags,
18 sameShape,
19 syncIotWirelessTags,
20} from "./internal.ts";
21
22/**
23 * LoRaWAN OTAA v1.1 activation keys. The root keys (`AppKey`, `NwkKey`) are
24 * secrets — wrap them with `Redacted.make(...)`.
25 */
26export interface OtaaV1_1Props {
27 /** The AppKey root key. Secret key material. */
28 AppKey?: Redacted.Redacted<string>;
29 /** The NwkKey root key. Secret key material. */
30 NwkKey?: Redacted.Redacted<string>;
31 /** The JoinEUI identifier (public). */
32 JoinEui?: string;
33}
34
35/**
36 * LoRaWAN OTAA v1.0.x activation keys. The root keys (`AppKey`, `GenAppKey`)
37 * are secrets — wrap them with `Redacted.make(...)`.
38 */
39export interface OtaaV1_0_xProps {
40 /** The AppKey root key. Secret key material. */
41 AppKey?: Redacted.Redacted<string>;
42 /** The AppEUI identifier (public). */
43 AppEui?: string;
44 /** The JoinEUI identifier (public). */
45 JoinEui?: string;
46 /** The GenAppKey root key. Secret key material. */
47 GenAppKey?: Redacted.Redacted<string>;
48}
49
50/**
51 * LoRaWAN ABP v1.1 session keys. All four keys are secrets — wrap them with
52 * `Redacted.make(...)`.
53 */
54export interface SessionKeysAbpV1_1Props {
55 /** The FNwkSIntKey session key. Secret key material. */
56 FNwkSIntKey?: Redacted.Redacted<string>;
57 /** The SNwkSIntKey session key. Secret key material. */
58 SNwkSIntKey?: Redacted.Redacted<string>;
59 /** The NwkSEncKey session key. Secret key material. */
60 NwkSEncKey?: Redacted.Redacted<string>;
61 /** The AppSKey session key. Secret key material. */
62 AppSKey?: Redacted.Redacted<string>;
63}
64
65/** LoRaWAN ABP v1.1 activation configuration. */
66export interface AbpV1_1Props {
67 /** The device address (public). */
68 DevAddr?: string;
69 /** Session keys. Secret key material. */
70 SessionKeys?: SessionKeysAbpV1_1Props;
71 /** The initial FCnt value. */
72 FCntStart?: number;
73}
74
75/**
76 * LoRaWAN ABP v1.0.x session keys. Both keys are secrets — wrap them with
77 * `Redacted.make(...)`.
78 */
79export interface SessionKeysAbpV1_0_xProps {
80 /** The NwkSKey session key. Secret key material. */
81 NwkSKey?: Redacted.Redacted<string>;
82 /** The AppSKey session key. Secret key material. */
83 AppSKey?: Redacted.Redacted<string>;
84}
85
86/** LoRaWAN ABP v1.0.x activation configuration. */
87export interface AbpV1_0_xProps {
88 /** The device address (public). */
89 DevAddr?: string;
90 /** Session keys. Secret key material. */
91 SessionKeys?: SessionKeysAbpV1_0_xProps;
92 /** The initial FCnt value. */
93 FCntStart?: number;
94}
95
96/**
97 * LoRaWAN device configuration. Mirrors the wire shape, with the activation
98 * key material typed as `Redacted` so secrets never leak into logs or state
99 * diffs.
100 */
101export interface LoRaWANDeviceProps {
102 /** The DevEUI radio identifier (public). Changing it replaces the device. */
103 DevEui?: string;
104 /** ID of the device profile the device uses. Updates in place. */
105 DeviceProfileId?: string;
106 /** ID of the service profile the device uses. Updates in place. */
107 ServiceProfileId?: string;
108 /** OTAA v1.1 activation keys. Changing them replaces the device. */
109 OtaaV1_1?: OtaaV1_1Props;
110 /** OTAA v1.0.x activation keys. Changing them replaces the device. */
111 OtaaV1_0_x?: OtaaV1_0_xProps;
112 /** ABP v1.1 activation configuration. Changing it replaces the device. */
113 AbpV1_1?: AbpV1_1Props;
114 /** ABP v1.0.x activation configuration. Changing it replaces the device. */
115 AbpV1_0_x?: AbpV1_0_xProps;
116 /** FPort configuration. Updates in place. */
117 FPorts?: iotw.FPorts;
118}
119
120export interface WirelessDeviceProps {
121 /**
122 * The wireless technology the device uses. Changing the type replaces the
123 * device.
124 */
125 type: "LoRaWAN" | "Sidewalk";
126 /**
127 * Name of the destination that routes the device's uplink messages. Can be
128 * changed in place.
129 */
130 destinationName: string;
131 /**
132 * Name of the wireless device. If omitted, a deterministic physical name
133 * is generated from the app, stage, and logical ID. Can be changed in
134 * place.
135 */
136 name?: string;
137 /**
138 * Human-readable description of the device.
139 */
140 description?: string;
141 /**
142 * LoRaWAN device configuration: `DevEui`, the device/service profile IDs,
143 * and the activation keys (`OtaaV1_0_x`, `OtaaV1_1`, `AbpV1_0_x`,
144 * `AbpV1_1`). The `DevEui` and activation keys are the device's radio
145 * identity — changing them replaces the device; the profile IDs and
146 * `FPorts` update in place. Key material is secret — wrap each key with
147 * `Redacted.make(...)`.
148 */
149 loRaWAN?: LoRaWANDeviceProps;
150 /**
151 * Whether position solving is enabled for the device (`Enabled` or
152 * `Disabled`).
153 */
154 positioning?: iotw.PositioningConfigStatus;
155 /**
156 * Amazon Sidewalk device configuration. Changing the manufacturing serial
157 * number replaces the device.
158 */
159 sidewalk?: iotw.SidewalkCreateWirelessDevice;
160 /**
161 * Tags applied to the device. Alchemy ownership tags are merged in
162 * automatically.
163 */
164 tags?: Record<string, string>;
165}
166
167export interface WirelessDevice extends Resource<
168 "AWS.IoTWireless.WirelessDevice",
169 WirelessDeviceProps,
170 {
171 /** Server-assigned ID of the wireless device. */
172 wirelessDeviceId: string;
173 /** ARN of the wireless device. */
174 wirelessDeviceArn: string;
175 /** Name of the wireless device. */
176 wirelessDeviceName: string;
177 /** The wireless technology the device uses. */
178 type: iotw.WirelessDeviceType;
179 /** Name of the destination routing the device's uplinks. */
180 destinationName: string;
181 },
182 never,
183 Providers
184> {}
185
186/**
187 * An AWS IoT Core for LoRaWAN (or Amazon Sidewalk) wireless device — the
188 * cloud registration of a physical radio, wired to a {@link Destination}
189 * for uplink routing and to a {@link DeviceProfile} / {@link ServiceProfile}
190 * pair for its radio parameters.
191 *
192 * The device's radio identity (`type`, `DevEui`, activation keys) is
193 * immutable — changing it replaces the device. The name, description,
194 * destination, positioning, profile references, and tags update in place.
195 * ### Creating Devices
196 * **Example:** OTAA v1.0.x LoRaWAN Device
197 * ```typescript
198 * import * as IoTWireless from "alchemy/AWS/IoTWireless";
199 *
200 * const device = yield* IoTWireless.WirelessDevice("Sensor", {
201 * type: "LoRaWAN",
202 * destinationName: destination.destinationName,
203 * loRaWAN: {
204 * DevEui: "1122334455667788",
205 * DeviceProfileId: deviceProfile.deviceProfileId,
206 * ServiceProfileId: serviceProfile.serviceProfileId,
207 * OtaaV1_0_x: {
208 * AppKey: Redacted.make("00112233445566778899aabbccddeeff"),
209 * AppEui: "8877665544332211",
210 * },
211 * },
212 * });
213 * ```
214 *
215 * **Example:** Repoint a device at a different destination
216 * ```typescript
217 * const device = yield* IoTWireless.WirelessDevice("Sensor", {
218 * type: "LoRaWAN",
219 * destinationName: otherDestination.destinationName, // updates in place
220 * loRaWAN: { ... },
221 * });
222 * ```
223 *
224 * @resource
225 */
226export const WirelessDevice = Resource<WirelessDevice>(
227 "AWS.IoTWireless.WirelessDevice",
228);
229
230const unwrap = (value: Redacted.Redacted<string> | undefined) =>
231 value === undefined ? undefined : Redacted.value(value);
232
233/**
234 * Convert the Redacted-keyed LoRaWAN prop shape to the plain wire shape the
235 * IoT Wireless API expects.
236 */
237const toWireLoRaWAN = (
238 loRaWAN: LoRaWANDeviceProps | undefined,
239): iotw.LoRaWANDevice | undefined =>
240 loRaWAN === undefined
241 ? undefined
242 : {
243 DevEui: loRaWAN.DevEui,
244 DeviceProfileId: loRaWAN.DeviceProfileId,
245 ServiceProfileId: loRaWAN.ServiceProfileId,
246 OtaaV1_1:
247 loRaWAN.OtaaV1_1 === undefined
248 ? undefined
249 : {
250 AppKey: unwrap(loRaWAN.OtaaV1_1.AppKey),
251 NwkKey: unwrap(loRaWAN.OtaaV1_1.NwkKey),
252 JoinEui: loRaWAN.OtaaV1_1.JoinEui,
253 },
254 OtaaV1_0_x:
255 loRaWAN.OtaaV1_0_x === undefined
256 ? undefined
257 : {
258 AppKey: unwrap(loRaWAN.OtaaV1_0_x.AppKey),
259 AppEui: loRaWAN.OtaaV1_0_x.AppEui,
260 JoinEui: loRaWAN.OtaaV1_0_x.JoinEui,
261 GenAppKey: unwrap(loRaWAN.OtaaV1_0_x.GenAppKey),
262 },
263 AbpV1_1:
264 loRaWAN.AbpV1_1 === undefined
265 ? undefined
266 : {
267 DevAddr: loRaWAN.AbpV1_1.DevAddr,
268 FCntStart: loRaWAN.AbpV1_1.FCntStart,
269 SessionKeys:
270 loRaWAN.AbpV1_1.SessionKeys === undefined
271 ? undefined
272 : {
273 FNwkSIntKey: unwrap(
274 loRaWAN.AbpV1_1.SessionKeys.FNwkSIntKey,
275 ),
276 SNwkSIntKey: unwrap(
277 loRaWAN.AbpV1_1.SessionKeys.SNwkSIntKey,
278 ),
279 NwkSEncKey: unwrap(
280 loRaWAN.AbpV1_1.SessionKeys.NwkSEncKey,
281 ),
282 AppSKey: unwrap(loRaWAN.AbpV1_1.SessionKeys.AppSKey),
283 },
284 },
285 AbpV1_0_x:
286 loRaWAN.AbpV1_0_x === undefined
287 ? undefined
288 : {
289 DevAddr: loRaWAN.AbpV1_0_x.DevAddr,
290 FCntStart: loRaWAN.AbpV1_0_x.FCntStart,
291 SessionKeys:
292 loRaWAN.AbpV1_0_x.SessionKeys === undefined
293 ? undefined
294 : {
295 NwkSKey: unwrap(loRaWAN.AbpV1_0_x.SessionKeys.NwkSKey),
296 AppSKey: unwrap(loRaWAN.AbpV1_0_x.SessionKeys.AppSKey),
297 },
298 },
299 FPorts: loRaWAN.FPorts,
300 };
301
302/** The parts of a LoRaWAN device spec that form its immutable radio identity. */
303const loRaWANIdentity = (loRaWAN: iotw.LoRaWANDevice | undefined) => ({
304 DevEui: loRaWAN?.DevEui,
305 OtaaV1_0_x: loRaWAN?.OtaaV1_0_x,
306 OtaaV1_1: loRaWAN?.OtaaV1_1,
307 AbpV1_0_x: loRaWAN?.AbpV1_0_x,
308 AbpV1_1: loRaWAN?.AbpV1_1,
309});
310
311export const WirelessDeviceProvider = () =>
312 Provider.effect(
313 WirelessDevice,
314 Effect.gen(function* () {
315 const createName = Effect.fn(function* (
316 id: string,
317 props: { name?: string },
318 ) {
319 return (
320 props.name ?? (yield* createPhysicalName({ id, maxLength: 256 }))
321 );
322 });
323
324 const getBy = (identifier: string, type: iotw.WirelessDeviceIdType) =>
325 iotw
326 .getWirelessDevice({ Identifier: identifier, IdentifierType: type })
327 .pipe(
328 Effect.catchTag("ResourceNotFoundException", () =>
329 Effect.succeed(undefined),
330 ),
331 );
332
333 const observe = Effect.fn(function* (
334 output: WirelessDevice["Attributes"] | undefined,
335 props: WirelessDeviceProps,
336 ) {
337 if (output?.wirelessDeviceId !== undefined) {
338 const found = yield* getBy(
339 output.wirelessDeviceId,
340 "WirelessDeviceId",
341 );
342 if (found !== undefined) return found;
343 }
344 // Recover identity by the device's radio identifier when state was
345 // lost — the DevEui / Sidewalk serial is unique per account.
346 if (props.loRaWAN?.DevEui !== undefined) {
347 return yield* getBy(props.loRaWAN.DevEui, "DevEui");
348 }
349 if (props.sidewalk?.SidewalkManufacturingSn !== undefined) {
350 return yield* getBy(
351 props.sidewalk.SidewalkManufacturingSn,
352 "SidewalkManufacturingSn",
353 );
354 }
355 return undefined;
356 });
357
358 const toAttrs = Effect.fn(function* (
359 device: iotw.GetWirelessDeviceResponse,
360 name: string,
361 ) {
362 if (device.Id === undefined || device.Arn === undefined) {
363 return yield* Effect.fail(
364 new Error(`IoT Wireless device '${name}' returned without Id/Arn`),
365 );
366 }
367 return {
368 wirelessDeviceId: device.Id,
369 wirelessDeviceArn: device.Arn,
370 wirelessDeviceName: device.Name ?? name,
371 type: device.Type ?? "LoRaWAN",
372 destinationName: device.DestinationName ?? "",
373 };
374 });
375
376 return WirelessDevice.Provider.of({
377 stables: ["wirelessDeviceId", "wirelessDeviceArn", "type"],
378
379 list: () =>
380 iotw.listWirelessDevices.pages({}).pipe(
381 Stream.runCollect,
382 Effect.map((chunk) =>
383 Array.from(chunk)
384 .flatMap((page) => page.WirelessDeviceList ?? [])
385 .flatMap((d) =>
386 d.Id !== undefined && d.Arn !== undefined
387 ? [
388 {
389 wirelessDeviceId: d.Id,
390 wirelessDeviceArn: d.Arn,
391 wirelessDeviceName: d.Name ?? d.Id,
392 type: d.Type ?? "LoRaWAN",
393 destinationName: d.DestinationName ?? "",
394 },
395 ]
396 : [],
397 ),
398 ),
399 ),
400
401 read: Effect.fn(function* ({ id, olds, output }) {
402 const props = olds ?? { type: "LoRaWAN", destinationName: "" };
403 const name =
404 output?.wirelessDeviceName ?? (yield* createName(id, props));
405 const device = yield* observe(output, props);
406 if (device === undefined) return undefined;
407 const attrs = yield* toAttrs(device, name);
408 const tags = yield* readIotWirelessTags(attrs.wirelessDeviceArn);
409 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
410 }),
411
412 // The wireless technology and the radio identity (DevEui, activation
413 // keys, Sidewalk serial) are create-only. Name, description,
414 // destination, positioning, and profile references update in place.
415 diff: Effect.fn(function* ({ news, olds }) {
416 if (!isResolved(news)) return undefined;
417 if (olds !== undefined && olds.type !== news.type) {
418 return { action: "replace" } as const;
419 }
420 if (
421 !sameShape(
422 loRaWANIdentity(toWireLoRaWAN(olds?.loRaWAN)),
423 loRaWANIdentity(toWireLoRaWAN(news.loRaWAN)),
424 )
425 ) {
426 return { action: "replace" } as const;
427 }
428 if (
429 olds?.sidewalk?.SidewalkManufacturingSn !==
430 news.sidewalk?.SidewalkManufacturingSn
431 ) {
432 return { action: "replace" } as const;
433 }
434 }),
435
436 reconcile: Effect.fn(function* ({ id, news, output, session }) {
437 const name =
438 output?.wirelessDeviceName ?? (yield* createName(id, news));
439 const internalTags = yield* createInternalTags(id);
440 const desiredTags = { ...news.tags, ...internalTags };
441
442 // 1. OBSERVE — cloud state is authoritative; output is an id cache.
443 let device = yield* observe(output, news);
444
445 // 2. ENSURE — create if missing; a Conflict means the radio
446 // identity is already registered, so re-observe by it.
447 if (device === undefined) {
448 yield* session.note(`creating wireless device ${name}`);
449 const created = yield* iotw
450 .createWirelessDevice({
451 Type: news.type,
452 Name: name,
453 Description: news.description,
454 DestinationName: news.destinationName,
455 LoRaWAN: toWireLoRaWAN(news.loRaWAN),
456 Positioning: news.positioning,
457 Sidewalk: news.sidewalk,
458 Tags: createTagsList(desiredTags),
459 })
460 .pipe(
461 Effect.catchTag("ConflictException", () =>
462 Effect.succeed(undefined),
463 ),
464 );
465 device =
466 created?.Id !== undefined
467 ? yield* getBy(created.Id, "WirelessDeviceId")
468 : yield* observe(undefined, news);
469 }
470 if (device === undefined) {
471 return yield* Effect.fail(
472 new Error(`IoT Wireless device '${name}' not found after create`),
473 );
474 }
475 const attrs = yield* toAttrs(device, name);
476
477 // 3. SYNC — apply the mutable-aspect delta from OBSERVED state.
478 const nameDelta = device.Name !== name;
479 const descriptionDelta =
480 news.description !== undefined &&
481 device.Description !== news.description;
482 const destinationDelta =
483 device.DestinationName !== news.destinationName;
484 const positioningDelta =
485 news.positioning !== undefined &&
486 device.Positioning !== news.positioning;
487 const profileDelta =
488 news.loRaWAN !== undefined &&
489 (device.LoRaWAN?.DeviceProfileId !== news.loRaWAN.DeviceProfileId ||
490 device.LoRaWAN?.ServiceProfileId !==
491 news.loRaWAN.ServiceProfileId ||
492 !sameShape(device.LoRaWAN?.FPorts, news.loRaWAN.FPorts));
493 if (
494 nameDelta ||
495 descriptionDelta ||
496 destinationDelta ||
497 positioningDelta ||
498 profileDelta
499 ) {
500 yield* iotw.updateWirelessDevice({
501 Id: attrs.wirelessDeviceId,
502 Name: name,
503 Description: news.description,
504 DestinationName: news.destinationName,
505 Positioning: news.positioning,
506 LoRaWAN:
507 news.loRaWAN === undefined
508 ? undefined
509 : {
510 DeviceProfileId: news.loRaWAN.DeviceProfileId,
511 ServiceProfileId: news.loRaWAN.ServiceProfileId,
512 FPorts: news.loRaWAN.FPorts,
513 },
514 });
515 }
516
517 // 3b. SYNC tags — diff against OBSERVED cloud tags.
518 yield* syncIotWirelessTags(attrs.wirelessDeviceArn, desiredTags);
519
520 // 4. RETURN fresh attributes.
521 const final = yield* getBy(
522 attrs.wirelessDeviceId,
523 "WirelessDeviceId",
524 );
525 if (final === undefined) {
526 return yield* Effect.fail(
527 new Error(`IoT Wireless device '${name}' vanished during update`),
528 );
529 }
530 yield* session.note(attrs.wirelessDeviceId);
531 return yield* toAttrs(final, name);
532 }),
533
534 delete: Effect.fn(function* ({ output }) {
535 yield* iotw
536 .deleteWirelessDevice({ Id: output.wirelessDeviceId })
537 .pipe(
538 Effect.asVoid,
539 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
540 );
541 }),
542 });
543 }),
544 );