1import * as mi from "@distilled.cloud/aws/iot-managed-integrations";
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 { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 syncManagedIntegrationsTags,
14 toTagRecord,
15 unwrapSensitive,
16} from "./internal.ts";
17
18export interface ManagedThingProps {
19 /**
20 * Role of the device in Managed integrations: a `CONTROLLER` onboards and
21 * controls devices; a `DEVICE` is an endpoint. Changing the role replaces
22 * the managed thing.
23 */
24 role: mi.Role;
25 /**
26 * Authentication material used to onboard the device (e.g. the payload of
27 * a Wi-Fi setup or Zigbee QR bar code). This is a device-onboarding
28 * credential — wrap it with `Redacted.make(...)`. Create-only — changing
29 * it replaces the managed thing.
30 */
31 authenticationMaterial: Redacted.Redacted<string>;
32 /**
33 * Type of the authentication material.
34 * E.g. `WIFI_SETUP_QR_BAR_CODE`, `ZIGBEE_QR_BAR_CODE`, `ZWAVE_QR_BAR_CODE`.
35 * Create-only — changing it replaces the managed thing.
36 */
37 authenticationMaterialType: mi.AuthMaterialType;
38 /**
39 * Display name of the managed thing. If omitted, a unique name is generated
40 * from the app, stage, and logical ID.
41 */
42 name?: string;
43 /**
44 * Identifier of the credential locker used by the managed thing.
45 */
46 credentialLockerId?: string;
47 /**
48 * Owner of the managed thing.
49 */
50 owner?: string;
51 /**
52 * Serial number of the device.
53 */
54 serialNumber?: string;
55 /**
56 * Brand of the device.
57 */
58 brand?: string;
59 /**
60 * Model of the device.
61 */
62 model?: string;
63 /**
64 * Classification of the managed thing.
65 */
66 classification?: string;
67 /**
68 * Capability report of the device.
69 */
70 capabilityReport?: mi.CapabilityReport;
71 /**
72 * Metadata key-value pairs for the managed thing.
73 */
74 metaData?: Record<string, string>;
75 /**
76 * User-defined tags to apply to the managed thing.
77 */
78 tags?: Record<string, string>;
79}
80
81export interface ManagedThing extends Resource<
82 "AWS.IoTManagedIntegrations.ManagedThing",
83 ManagedThingProps,
84 {
85 /** Service-generated identifier of the managed thing. */
86 managedThingId: string;
87 /** ARN of the managed thing. */
88 managedThingArn: string;
89 /** Display name of the managed thing. */
90 managedThingName: string;
91 /** Role of the device (CONTROLLER or DEVICE). */
92 role: mi.Role;
93 /** Provisioning status of the managed thing. */
94 provisioningStatus: mi.ProvisioningStatus | undefined;
95 /** Tags applied to the managed thing (user + internal). */
96 tags: Record<string, string>;
97 },
98 never,
99 Providers
100> {}
101
102/**
103 * An AWS IoT Managed Integrations managed thing — the cloud representation of
104 * a device (or controller) onboarded through Managed integrations, carrying
105 * its identity, protocol, and capability data model.
106 *
107 * Creating a managed thing requires real device onboarding material (e.g. a
108 * Wi-Fi Simple Setup or Zigbee QR bar code payload). IoT Managed Integrations
109 * is a regional service available in a limited set of regions (e.g.
110 * `eu-west-1`, `ca-central-1`).
111 *
112 * ### Creating Managed Things
113 * **Example:** Controller from a Wi-Fi Setup QR Code
114 * ```typescript
115 * const thing = yield* ManagedThing("Hub", {
116 * role: "CONTROLLER",
117 * authenticationMaterial: Redacted.make(wifiSetupQrCodePayload),
118 * authenticationMaterialType: "WIFI_SETUP_QR_BAR_CODE",
119 * });
120 * ```
121 *
122 * **Example:** Device with a Credential Locker
123 * ```typescript
124 * const locker = yield* CredentialLocker("DeviceCredentials", {});
125 * const thing = yield* ManagedThing("Sensor", {
126 * role: "DEVICE",
127 * authenticationMaterial: Redacted.make(zigbeeQrCodePayload),
128 * authenticationMaterialType: "ZIGBEE_QR_BAR_CODE",
129 * credentialLockerId: locker.credentialLockerId,
130 * serialNumber: "SN-0001",
131 * });
132 * ```
133 *
134 * @resource
135 */
136export const ManagedThing = Resource<ManagedThing>(
137 "AWS.IoTManagedIntegrations.ManagedThing",
138);
139
140export const ManagedThingProvider = () =>
141 Provider.effect(
142 ManagedThing,
143 Effect.gen(function* () {
144 const toName = (id: string, props: { name?: string } = {}) =>
145 props.name
146 ? Effect.succeed(props.name)
147 : createPhysicalName({ id, maxLength: 64 });
148
149 const observeById = (identifier: string) =>
150 mi
151 .getManagedThing({ Identifier: identifier })
152 .pipe(
153 Effect.catchTag("ResourceNotFoundException", () =>
154 Effect.succeed(undefined),
155 ),
156 );
157
158 // Recover an existing managed thing by display name when no Id is
159 // cached (e.g. after a state persistence failure).
160 const findByName = Effect.fn(function* (name: string) {
161 const summary = yield* mi.listManagedThings.items({}).pipe(
162 Stream.filter((item) => item.Name === name),
163 Stream.take(1),
164 Stream.runCollect,
165 Effect.map((chunk) => Array.from(chunk)[0]),
166 );
167 if (summary?.Id === undefined) return undefined;
168 return yield* observeById(summary.Id);
169 });
170
171 const toAttributes = Effect.fn(function* (
172 thing: mi.GetManagedThingResponse,
173 ) {
174 if (
175 thing.Id === undefined ||
176 thing.Arn === undefined ||
177 thing.Name === undefined ||
178 thing.Role === undefined
179 ) {
180 return yield* Effect.fail(
181 new Error(
182 "managed thing response is missing Id, Arn, Name, or Role",
183 ),
184 );
185 }
186 return {
187 managedThingId: thing.Id,
188 managedThingArn: thing.Arn,
189 managedThingName: thing.Name,
190 role: thing.Role,
191 provisioningStatus: thing.ProvisioningStatus,
192 tags: toTagRecord(thing.Tags),
193 };
194 });
195
196 return {
197 stables: ["managedThingId", "managedThingArn"],
198 diff: Effect.fn(function* ({ olds, news }) {
199 if (!isResolved(news)) return;
200 if (olds === undefined) return;
201 // Role and authentication material are create-only.
202 if (
203 olds.role !== news.role ||
204 Redacted.value(olds.authenticationMaterial) !==
205 Redacted.value(news.authenticationMaterial) ||
206 olds.authenticationMaterialType !== news.authenticationMaterialType
207 ) {
208 return { action: "replace" } as const;
209 }
210 }),
211 read: Effect.fn(function* ({ id, olds, output }) {
212 const thing =
213 output?.managedThingId !== undefined
214 ? yield* observeById(output.managedThingId)
215 : yield* findByName(yield* toName(id, olds ?? {}));
216 if (thing === undefined) return undefined;
217 const attrs = yield* toAttributes(thing);
218 return (yield* hasAlchemyTags(id, attrs.tags))
219 ? attrs
220 : Unowned(attrs);
221 }),
222 reconcile: Effect.fn(function* ({ id, news, output, session }) {
223 const name = yield* toName(id, news);
224 const internalTags = yield* createInternalTags(id);
225 const desiredTags = { ...internalTags, ...news.tags };
226
227 // Observe — cloud state is authoritative; output is only an Id cache.
228 let thing =
229 output?.managedThingId !== undefined
230 ? yield* observeById(output.managedThingId)
231 : yield* findByName(name);
232
233 // Ensure — create if missing.
234 if (thing === undefined) {
235 const created = yield* mi.createManagedThing({
236 Role: news.role,
237 AuthenticationMaterial: news.authenticationMaterial,
238 AuthenticationMaterialType: news.authenticationMaterialType,
239 Name: name,
240 CredentialLockerId: news.credentialLockerId,
241 Owner: news.owner,
242 SerialNumber: news.serialNumber,
243 Brand: news.brand,
244 Model: news.model,
245 Classification: news.classification,
246 CapabilityReport: news.capabilityReport,
247 MetaData: news.metaData,
248 Tags: desiredTags,
249 });
250 if (created.Id === undefined) {
251 return yield* Effect.fail(
252 new Error(`failed to create managed thing '${name}'`),
253 );
254 }
255 thing = yield* observeById(created.Id);
256 if (thing === undefined) {
257 return yield* Effect.fail(
258 new Error(
259 `managed thing '${created.Id}' vanished after create`,
260 ),
261 );
262 }
263 } else if (thing.Id !== undefined) {
264 // Sync mutable, observable fields — apply only the delta.
265 const observed = {
266 name: thing.Name,
267 credentialLockerId: thing.CredentialLockerId,
268 owner: unwrapSensitive(thing.Owner),
269 serialNumber: unwrapSensitive(thing.SerialNumber),
270 brand: unwrapSensitive(thing.Brand),
271 model: unwrapSensitive(thing.Model),
272 classification: unwrapSensitive(thing.Classification),
273 };
274 const desired = {
275 name,
276 credentialLockerId: news.credentialLockerId,
277 owner: news.owner,
278 serialNumber: news.serialNumber,
279 brand: news.brand,
280 model: news.model,
281 classification: news.classification,
282 };
283 const changed = (
284 Object.keys(desired) as (keyof typeof desired)[]
285 ).filter(
286 (key) =>
287 desired[key] !== undefined && desired[key] !== observed[key],
288 );
289 if (changed.length > 0) {
290 yield* mi.updateManagedThing({
291 Identifier: thing.Id,
292 Name: name,
293 CredentialLockerId: news.credentialLockerId,
294 Owner: news.owner,
295 SerialNumber: news.serialNumber,
296 Brand: news.brand,
297 Model: news.model,
298 Classification: news.classification,
299 MetaData: news.metaData,
300 });
301 thing = yield* observeById(thing.Id);
302 if (thing === undefined) {
303 return yield* Effect.fail(
304 new Error(`managed thing '${name}' vanished during update`),
305 );
306 }
307 }
308 }
309
310 // Sync tags — diff against OBSERVED cloud tags.
311 if (thing.Arn !== undefined) {
312 yield* syncManagedIntegrationsTags(
313 thing.Arn,
314 toTagRecord(thing.Tags),
315 desiredTags,
316 );
317 }
318
319 const attrs = yield* toAttributes(thing);
320 yield* session.note(attrs.managedThingArn);
321 return { ...attrs, tags: desiredTags };
322 }),
323 // Enumerate every managed thing in the account/region; fetch each one
324 // to resolve tags (summaries omit them).
325 list: () =>
326 Effect.gen(function* () {
327 const summaries = yield* mi.listManagedThings.items({}).pipe(
328 Stream.runCollect,
329 Effect.map((chunk) => Array.from(chunk)),
330 );
331 const things = yield* Effect.forEach(
332 summaries.filter(
333 (s): s is mi.ManagedThingSummary & { Id: string } =>
334 s.Id !== undefined,
335 ),
336 (summary) => observeById(summary.Id),
337 { concurrency: 5 },
338 );
339 return yield* Effect.forEach(
340 things.filter(
341 (thing): thing is mi.GetManagedThingResponse =>
342 thing !== undefined,
343 ),
344 toAttributes,
345 );
346 }),
347 delete: Effect.fn(function* ({ output }) {
348 yield* mi
349 .deleteManagedThing({ Identifier: output.managedThingId })
350 .pipe(
351 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
352 );
353 }),
354 };
355 }),
356 );