1import * as iotw from "@distilled.cloud/aws/iot-wireless";
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 {
10 createInternalTags,
11 createTagsList,
12 hasAlchemyTags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 readIotWirelessTags,
17 sameShape,
18 syncIotWirelessTags,
19} from "./internal.ts";
20
21export interface DeviceProfileProps {
22 /**
23 * Name of the device profile. If omitted, a deterministic physical name
24 * is generated from the app, stage, and logical ID. Device profiles are
25 * immutable — changing the name replaces the profile.
26 */
27 name?: string;
28 /**
29 * LoRaWAN device profile configuration (MAC version, regional parameters,
30 * RX windows, class B/C support). Immutable — changing any field replaces
31 * the profile.
32 */
33 loRaWAN?: iotw.LoRaWANDeviceProfile;
34 /**
35 * Sidewalk device profile configuration. Set to `{}` to create an Amazon
36 * Sidewalk device profile instead of a LoRaWAN one. Immutable.
37 */
38 sidewalk?: iotw.SidewalkCreateDeviceProfile;
39 /**
40 * Tags applied to the device profile. Alchemy ownership tags are merged
41 * in automatically.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface DeviceProfile extends Resource<
47 "AWS.IoTWireless.DeviceProfile",
48 DeviceProfileProps,
49 {
50 /** Server-assigned ID of the device profile. */
51 deviceProfileId: string;
52 /** ARN of the device profile. */
53 deviceProfileArn: string;
54 /** Name of the device profile. */
55 deviceProfileName: string;
56 },
57 never,
58 Providers
59> {}
60
61/**
62 * An AWS IoT Core for LoRaWAN device profile — the hardware-level LoRaWAN
63 * parameters (MAC version, regional band, RX windows, device classes) shared
64 * by devices of the same model.
65 *
66 * Device profiles are immutable after creation: any change to `name`,
67 * `loRaWAN`, or `sidewalk` replaces the profile. Only tags update in place.
68 * ### Creating Device Profiles
69 * **Example:** US915 OTAA Device Profile
70 * ```typescript
71 * import * as IoTWireless from "alchemy/AWS/IoTWireless";
72 *
73 * const profile = yield* IoTWireless.DeviceProfile("SensorModel", {
74 * loRaWAN: {
75 * MacVersion: "1.0.3",
76 * RegParamsRevision: "RP002-1.0.1",
77 * RfRegion: "US915",
78 * MaxEirp: 10,
79 * SupportsJoin: true,
80 * },
81 * });
82 * ```
83 *
84 * **Example:** Sidewalk Device Profile
85 * ```typescript
86 * const profile = yield* IoTWireless.DeviceProfile("SidewalkModel", {
87 * sidewalk: {},
88 * });
89 * ```
90 *
91 * @resource
92 */
93export const DeviceProfile = Resource<DeviceProfile>(
94 "AWS.IoTWireless.DeviceProfile",
95);
96
97export const DeviceProfileProvider = () =>
98 Provider.effect(
99 DeviceProfile,
100 Effect.gen(function* () {
101 const createName = Effect.fn(function* (
102 id: string,
103 props: { name?: string },
104 ) {
105 return (
106 props.name ?? (yield* createPhysicalName({ id, maxLength: 256 }))
107 );
108 });
109
110 const getById = (deviceProfileId: string) =>
111 iotw
112 .getDeviceProfile({ Id: deviceProfileId })
113 .pipe(
114 Effect.catchTag("ResourceNotFoundException", () =>
115 Effect.succeed(undefined),
116 ),
117 );
118
119 // Device profiles have no name-keyed Get — enumerate and match. The
120 // physical name is deterministic, so this recovers identity after a
121 // lost state write.
122 const findByName = (name: string) =>
123 iotw.listDeviceProfiles.pages({}).pipe(
124 Stream.runCollect,
125 Effect.map((chunk) =>
126 Array.from(chunk)
127 .flatMap((page) => page.DeviceProfileList ?? [])
128 .find((p) => p.Name === name),
129 ),
130 );
131
132 const observe = Effect.fn(function* (
133 output: DeviceProfile["Attributes"] | undefined,
134 name: string,
135 ) {
136 if (output?.deviceProfileId !== undefined) {
137 const found = yield* getById(output.deviceProfileId);
138 if (found !== undefined) return found;
139 }
140 const summary = yield* findByName(name);
141 if (summary?.Id === undefined) return undefined;
142 return yield* getById(summary.Id);
143 });
144
145 const toAttrs = Effect.fn(function* (
146 profile: iotw.GetDeviceProfileResponse,
147 name: string,
148 ) {
149 if (profile.Id === undefined || profile.Arn === undefined) {
150 return yield* Effect.fail(
151 new Error(
152 `IoT Wireless device profile '${name}' returned without Id/Arn`,
153 ),
154 );
155 }
156 return {
157 deviceProfileId: profile.Id,
158 deviceProfileArn: profile.Arn,
159 deviceProfileName: profile.Name ?? name,
160 };
161 });
162
163 return DeviceProfile.Provider.of({
164 stables: ["deviceProfileId", "deviceProfileArn", "deviceProfileName"],
165
166 list: () =>
167 iotw.listDeviceProfiles.pages({}).pipe(
168 Stream.runCollect,
169 Effect.map((chunk) =>
170 Array.from(chunk)
171 .flatMap((page) => page.DeviceProfileList ?? [])
172 .flatMap((p) =>
173 p.Id !== undefined && p.Arn !== undefined
174 ? [
175 {
176 deviceProfileId: p.Id,
177 deviceProfileArn: p.Arn,
178 deviceProfileName: p.Name ?? p.Id,
179 },
180 ]
181 : [],
182 ),
183 ),
184 ),
185
186 read: Effect.fn(function* ({ id, olds, output }) {
187 const name =
188 output?.deviceProfileName ?? (yield* createName(id, olds ?? {}));
189 const profile = yield* observe(output, name);
190 if (profile === undefined) return undefined;
191 const attrs = yield* toAttrs(profile, name);
192 const tags = yield* readIotWirelessTags(attrs.deviceProfileArn);
193 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
194 }),
195
196 // There is no UpdateDeviceProfile API — name and radio config are
197 // create-only, so any change to either replaces the profile. Tags
198 // stay update-in-place.
199 diff: Effect.fn(function* ({ id, news, olds }) {
200 if (!isResolved(news)) return undefined;
201 const oldName = yield* createName(id, olds ?? {});
202 const newName = yield* createName(id, news);
203 if (oldName !== newName) return { action: "replace" } as const;
204 if (!sameShape(olds?.loRaWAN, news.loRaWAN)) {
205 return { action: "replace" } as const;
206 }
207 if (
208 (olds?.sidewalk === undefined) !==
209 (news.sidewalk === undefined)
210 ) {
211 return { action: "replace" } as const;
212 }
213 }),
214
215 reconcile: Effect.fn(function* ({ id, news, output, session }) {
216 const name =
217 output?.deviceProfileName ?? (yield* createName(id, news));
218 const internalTags = yield* createInternalTags(id);
219 const desiredTags = { ...news.tags, ...internalTags };
220
221 // 1. OBSERVE — cloud state is authoritative; output is an id cache.
222 let profile = yield* observe(output, name);
223
224 // 2. ENSURE — create if missing. CreateDeviceProfile always mints
225 // a new profile (names are not unique keys), so re-observing by
226 // name after a Conflict race keeps this idempotent.
227 if (profile === undefined) {
228 yield* session.note(`creating device profile ${name}`);
229 const created = yield* iotw
230 .createDeviceProfile({
231 Name: name,
232 LoRaWAN: news.loRaWAN,
233 Sidewalk: news.sidewalk,
234 Tags: createTagsList(desiredTags),
235 })
236 .pipe(
237 Effect.catchTag("ConflictException", () =>
238 Effect.succeed(undefined),
239 ),
240 );
241 profile =
242 created?.Id !== undefined
243 ? yield* getById(created.Id)
244 : yield* observe(undefined, name);
245 }
246 if (profile === undefined) {
247 return yield* Effect.fail(
248 new Error(
249 `IoT Wireless device profile '${name}' not found after create`,
250 ),
251 );
252 }
253
254 // 3. SYNC — only tags are mutable; diff against OBSERVED tags.
255 const attrs = yield* toAttrs(profile, name);
256 yield* syncIotWirelessTags(attrs.deviceProfileArn, desiredTags);
257
258 yield* session.note(attrs.deviceProfileId);
259 return attrs;
260 }),
261
262 delete: Effect.fn(function* ({ output }) {
263 yield* iotw.deleteDeviceProfile({ Id: output.deviceProfileId }).pipe(
264 Effect.asVoid,
265 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
266 );
267 }),
268 });
269 }),
270 );