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 WirelessGatewayProps {
22 /**
23 * Name of the wireless gateway. If omitted, a deterministic physical name
24 * is generated from the app, stage, and logical ID. Can be changed in
25 * place.
26 */
27 name?: string;
28 /**
29 * Human-readable description of the gateway.
30 */
31 description?: string;
32 /**
33 * LoRaWAN gateway configuration. `GatewayEui` (the gateway's unique
34 * 64-bit radio identifier) and `RfRegion` are the gateway's identity —
35 * changing either replaces the gateway. `JoinEuiFilters`, `NetIdFilters`,
36 * and `MaxEirp` update in place.
37 */
38 loRaWAN: iotw.LoRaWANGateway;
39 /**
40 * Tags applied to the gateway. Alchemy ownership tags are merged in
41 * automatically.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface WirelessGateway extends Resource<
47 "AWS.IoTWireless.WirelessGateway",
48 WirelessGatewayProps,
49 {
50 /** Server-assigned ID of the wireless gateway. */
51 wirelessGatewayId: string;
52 /** ARN of the wireless gateway. */
53 wirelessGatewayArn: string;
54 /** Name of the wireless gateway. */
55 wirelessGatewayName: string;
56 /** The gateway's unique 64-bit radio identifier. */
57 gatewayEui: string | undefined;
58 },
59 never,
60 Providers
61> {}
62
63/**
64 * An AWS IoT Core for LoRaWAN wireless gateway — the cloud registration of
65 * a physical LoRaWAN gateway (packet forwarder), keyed by its unique
66 * 64-bit `GatewayEui`.
67 *
68 * The gateway's radio identity (`GatewayEui`, `RfRegion`, sub-bands,
69 * beaconing) is immutable — changing it replaces the gateway. The name,
70 * description, EUI/NetID filters, `MaxEirp`, and tags update in place.
71 * ### Creating Gateways
72 * **Example:** US915 Gateway
73 * ```typescript
74 * import * as IoTWireless from "alchemy/AWS/IoTWireless";
75 *
76 * const gateway = yield* IoTWireless.WirelessGateway("RooftopGw", {
77 * loRaWAN: {
78 * GatewayEui: "aa555a0000000001",
79 * RfRegion: "US915",
80 * },
81 * tags: { site: "hq" },
82 * });
83 * ```
84 *
85 * **Example:** Gateway with join filters
86 * ```typescript
87 * const gateway = yield* IoTWireless.WirelessGateway("RooftopGw", {
88 * loRaWAN: {
89 * GatewayEui: "aa555a0000000001",
90 * RfRegion: "US915",
91 * JoinEuiFilters: [["0000000000000001", "00000000000000ff"]],
92 * MaxEirp: 30,
93 * },
94 * });
95 * ```
96 *
97 * @resource
98 */
99export const WirelessGateway = Resource<WirelessGateway>(
100 "AWS.IoTWireless.WirelessGateway",
101);
102
103/** The parts of a LoRaWAN gateway spec that form its immutable identity. */
104const gatewayIdentity = (loRaWAN: iotw.LoRaWANGateway | undefined) => ({
105 GatewayEui: loRaWAN?.GatewayEui,
106 RfRegion: loRaWAN?.RfRegion,
107 SubBands: loRaWAN?.SubBands,
108 Beaconing: loRaWAN?.Beaconing,
109});
110
111export const WirelessGatewayProvider = () =>
112 Provider.effect(
113 WirelessGateway,
114 Effect.gen(function* () {
115 const createName = Effect.fn(function* (
116 id: string,
117 props: { name?: string },
118 ) {
119 return (
120 props.name ?? (yield* createPhysicalName({ id, maxLength: 256 }))
121 );
122 });
123
124 const getBy = (identifier: string, type: iotw.WirelessGatewayIdType) =>
125 iotw
126 .getWirelessGateway({ Identifier: identifier, IdentifierType: type })
127 .pipe(
128 Effect.catchTag("ResourceNotFoundException", () =>
129 Effect.succeed(undefined),
130 ),
131 );
132
133 const observe = Effect.fn(function* (
134 output: WirelessGateway["Attributes"] | undefined,
135 props: WirelessGatewayProps,
136 ) {
137 if (output?.wirelessGatewayId !== undefined) {
138 const found = yield* getBy(
139 output.wirelessGatewayId,
140 "WirelessGatewayId",
141 );
142 if (found !== undefined) return found;
143 }
144 // Recover identity by the gateway's unique EUI when state was lost.
145 if (props.loRaWAN?.GatewayEui !== undefined) {
146 return yield* getBy(props.loRaWAN.GatewayEui, "GatewayEui");
147 }
148 return undefined;
149 });
150
151 const toAttrs = Effect.fn(function* (
152 gateway: iotw.GetWirelessGatewayResponse,
153 name: string,
154 ) {
155 if (gateway.Id === undefined || gateway.Arn === undefined) {
156 return yield* Effect.fail(
157 new Error(`IoT Wireless gateway '${name}' returned without Id/Arn`),
158 );
159 }
160 return {
161 wirelessGatewayId: gateway.Id,
162 wirelessGatewayArn: gateway.Arn,
163 wirelessGatewayName: gateway.Name ?? name,
164 gatewayEui: gateway.LoRaWAN?.GatewayEui,
165 };
166 });
167
168 return WirelessGateway.Provider.of({
169 stables: ["wirelessGatewayId", "wirelessGatewayArn", "gatewayEui"],
170
171 list: () =>
172 iotw.listWirelessGateways.pages({}).pipe(
173 Stream.runCollect,
174 Effect.map((chunk) =>
175 Array.from(chunk)
176 .flatMap((page) => page.WirelessGatewayList ?? [])
177 .flatMap((g) =>
178 g.Id !== undefined && g.Arn !== undefined
179 ? [
180 {
181 wirelessGatewayId: g.Id,
182 wirelessGatewayArn: g.Arn,
183 wirelessGatewayName: g.Name ?? g.Id,
184 gatewayEui: g.LoRaWAN?.GatewayEui,
185 },
186 ]
187 : [],
188 ),
189 ),
190 ),
191
192 read: Effect.fn(function* ({ id, olds, output }) {
193 const props = olds ?? { loRaWAN: {} };
194 const name =
195 output?.wirelessGatewayName ?? (yield* createName(id, props));
196 const gateway = yield* observe(output, props);
197 if (gateway === undefined) return undefined;
198 const attrs = yield* toAttrs(gateway, name);
199 const tags = yield* readIotWirelessTags(attrs.wirelessGatewayArn);
200 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
201 }),
202
203 // The radio identity (GatewayEui, RfRegion, sub-bands, beaconing) is
204 // create-only. Name, description, filters, and MaxEirp update in
205 // place via UpdateWirelessGateway.
206 diff: Effect.fn(function* ({ news, olds }) {
207 if (!isResolved(news)) return undefined;
208 if (
209 !sameShape(
210 gatewayIdentity(olds?.loRaWAN),
211 gatewayIdentity(news.loRaWAN),
212 )
213 ) {
214 return { action: "replace" } as const;
215 }
216 }),
217
218 reconcile: Effect.fn(function* ({ id, news, output, session }) {
219 const name =
220 output?.wirelessGatewayName ?? (yield* createName(id, news));
221 const internalTags = yield* createInternalTags(id);
222 const desiredTags = { ...news.tags, ...internalTags };
223
224 // 1. OBSERVE — cloud state is authoritative; output is an id cache.
225 let gateway = yield* observe(output, news);
226
227 // 2. ENSURE — create if missing; a Conflict means the EUI is
228 // already registered, so re-observe by it.
229 if (gateway === undefined) {
230 yield* session.note(`creating wireless gateway ${name}`);
231 const created = yield* iotw
232 .createWirelessGateway({
233 Name: name,
234 Description: news.description,
235 LoRaWAN: news.loRaWAN,
236 Tags: createTagsList(desiredTags),
237 })
238 .pipe(
239 Effect.catchTag("ConflictException", () =>
240 Effect.succeed(undefined),
241 ),
242 );
243 gateway =
244 created?.Id !== undefined
245 ? yield* getBy(created.Id, "WirelessGatewayId")
246 : yield* observe(undefined, news);
247 }
248 if (gateway === undefined) {
249 return yield* Effect.fail(
250 new Error(
251 `IoT Wireless gateway '${name}' not found after create`,
252 ),
253 );
254 }
255 const attrs = yield* toAttrs(gateway, name);
256
257 // 3. SYNC — apply the mutable-aspect delta from OBSERVED state.
258 const nameDelta = gateway.Name !== name;
259 const descriptionDelta =
260 news.description !== undefined &&
261 gateway.Description !== news.description;
262 const joinFiltersDelta =
263 news.loRaWAN.JoinEuiFilters !== undefined &&
264 !sameShape(
265 gateway.LoRaWAN?.JoinEuiFilters,
266 news.loRaWAN.JoinEuiFilters,
267 );
268 const netIdFiltersDelta =
269 news.loRaWAN.NetIdFilters !== undefined &&
270 !sameShape(
271 gateway.LoRaWAN?.NetIdFilters,
272 news.loRaWAN.NetIdFilters,
273 );
274 const maxEirpDelta =
275 news.loRaWAN.MaxEirp !== undefined &&
276 gateway.LoRaWAN?.MaxEirp !== news.loRaWAN.MaxEirp;
277 if (
278 nameDelta ||
279 descriptionDelta ||
280 joinFiltersDelta ||
281 netIdFiltersDelta ||
282 maxEirpDelta
283 ) {
284 yield* iotw.updateWirelessGateway({
285 Id: attrs.wirelessGatewayId,
286 Name: name,
287 Description: news.description,
288 JoinEuiFilters: news.loRaWAN.JoinEuiFilters,
289 NetIdFilters: news.loRaWAN.NetIdFilters,
290 MaxEirp: news.loRaWAN.MaxEirp,
291 });
292 }
293
294 // 3b. SYNC tags — diff against OBSERVED cloud tags.
295 yield* syncIotWirelessTags(attrs.wirelessGatewayArn, desiredTags);
296
297 // 4. RETURN fresh attributes.
298 const final = yield* getBy(
299 attrs.wirelessGatewayId,
300 "WirelessGatewayId",
301 );
302 if (final === undefined) {
303 return yield* Effect.fail(
304 new Error(
305 `IoT Wireless gateway '${name}' vanished during update`,
306 ),
307 );
308 }
309 yield* session.note(attrs.wirelessGatewayId);
310 return yield* toAttrs(final, name);
311 }),
312
313 delete: Effect.fn(function* ({ output }) {
314 yield* iotw
315 .deleteWirelessGateway({ Id: output.wirelessGatewayId })
316 .pipe(
317 Effect.asVoid,
318 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
319 );
320 }),
321 });
322 }),
323 );