1import * as AWS from "@/AWS";
2import * as IoTWireless from "@/AWS/IoTWireless";
3import * as Lambda from "@/AWS/Lambda";
4import * as Cause from "effect/Cause";
5import * as Context from "effect/Context";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Redacted from "effect/Redacted";
10import * as Result from "effect/Result";
11import * as Stream from "effect/Stream";
12import { HttpServerRequest } from "effect/http/HttpServerRequest";
13import * as HttpServerResponse from "effect/http/HttpServerResponse";
14import path from "pathe";
15
16const main = path.resolve(import.meta.dirname, "handler.ts");
17
18// Deterministic fabricated radio identities — an ABP device pre-provisions
19// its session keys, so the downlink queue works without any radio hardware
20// ever joining. Distinct from the identities used by IoTWireless.test.ts.
21const DEV_EUI = "1a2b3c4d5e6f7091";
22const DEV_ADDR = "01020304";
23const NWK_S_KEY = Redacted.make("000102030405060708090a0b0c0d0e0f");
24const APP_S_KEY = Redacted.make("0f0e0d0c0b0a09080706050403020100");
25const GATEWAY_EUI = "aa555a0000000201";
26
27// base64("Hello, World!")
28const PAYLOAD = "SGVsbG8sIFdvcmxkIQ==";
29
30export class IoTWirelessTestFunction extends Lambda.Function<Lambda.Function>()(
31 "IoTWirelessTestFunction",
32) {}
33
34// Result queue observed by the test: the DestinationEventSource handler
35// forwards every uplink routed through the destination into this queue so
36// delivery can be verified out-of-band.
37export class ResultQueue extends Context.Service<
38 ResultQueue,
39 { result: AWS.SQS.Queue }
40>()("IoTWirelessResultQueue") {}
41
42export const ResultQueueLive = Layer.effect(
43 ResultQueue,
44 Effect.gen(function* () {
45 const result = yield* AWS.SQS.Queue("IoTWirelessUplinkResultQueue");
46 return { result };
47 }),
48);
49
50export default IoTWirelessTestFunction.make(
51 {
52 main,
53 functionUrl: true,
54 timeout: Duration.seconds(30),
55 },
56 Effect.gen(function* () {
57 // The IAM role IoT Wireless assumes to deliver uplinks to the rule.
58 const role = yield* AWS.IAM.Role("IotWirelessBindingsDelivery", {
59 assumeRolePolicyDocument: {
60 Version: "2012-10-17",
61 Statement: [
62 {
63 Effect: "Allow",
64 Principal: { Service: "iotwireless.amazonaws.com" },
65 Action: ["sts:AssumeRole"],
66 },
67 ],
68 },
69 inlinePolicies: {
70 deliver: {
71 Version: "2012-10-17",
72 Statement: [
73 {
74 Effect: "Allow",
75 Action: ["iot:DescribeEndpoint", "iot:Publish"],
76 Resource: ["*"],
77 },
78 ],
79 },
80 },
81 });
82
83 const destination = yield* IoTWireless.Destination("BindingsUplinks", {
84 expressionType: "RuleName",
85 expression: "alchemy_iot_wireless_bindings_rule",
86 roleArn: role.roleArn,
87 tags: { fixture: "iot-wireless-bindings" },
88 });
89 const serviceProfile = yield* IoTWireless.ServiceProfile("BindingsFleet", {
90 loRaWAN: { AddGwMetadata: true },
91 tags: { fixture: "iot-wireless-bindings" },
92 });
93 const deviceProfile = yield* IoTWireless.DeviceProfile("BindingsModel", {
94 loRaWAN: {
95 MacVersion: "1.0.3",
96 RegParamsRevision: "RP002-1.0.1",
97 RfRegion: "US915",
98 MaxEirp: 10,
99 SupportsJoin: false,
100 },
101 tags: { fixture: "iot-wireless-bindings" },
102 });
103 const device = yield* IoTWireless.WirelessDevice("BindingsSensor", {
104 type: "LoRaWAN",
105 destinationName: destination.destinationName,
106 description: "alchemy iot-wireless bindings fixture device",
107 positioning: "Enabled",
108 loRaWAN: {
109 DevEui: DEV_EUI,
110 DeviceProfileId: deviceProfile.deviceProfileId,
111 ServiceProfileId: serviceProfile.serviceProfileId,
112 AbpV1_0_x: {
113 DevAddr: DEV_ADDR,
114 SessionKeys: { NwkSKey: NWK_S_KEY, AppSKey: APP_S_KEY },
115 },
116 },
117 tags: { fixture: "iot-wireless-bindings" },
118 });
119 const gateway = yield* IoTWireless.WirelessGateway("BindingsGateway", {
120 description: "alchemy iot-wireless bindings fixture gateway",
121 loRaWAN: { GatewayEui: GATEWAY_EUI, RfRegion: "US915" },
122 tags: { fixture: "iot-wireless-bindings" },
123 });
124
125 // device-scoped
126 const sendData = yield* IoTWireless.SendDataToWirelessDevice(device);
127 const listQueued = yield* IoTWireless.ListQueuedMessages(device);
128 const deleteQueued = yield* IoTWireless.DeleteQueuedMessages(device);
129 const getDeviceStats =
130 yield* IoTWireless.GetWirelessDeviceStatistics(device);
131 const testDevice = yield* IoTWireless.TestWirelessDevice(device);
132 const getPosition = yield* IoTWireless.GetResourcePosition(device);
133 const updatePosition = yield* IoTWireless.UpdateResourcePosition(device);
134 // gateway-scoped
135 const getGatewayStats =
136 yield* IoTWireless.GetWirelessGatewayStatistics(gateway);
137 // account-level
138 const getEndpoint = yield* IoTWireless.GetServiceEndpoint();
139 const estimatePosition = yield* IoTWireless.GetPositionEstimate();
140
141 // Event source: every uplink routed through the destination (including
142 // TestWirelessDevice's simulated "Hello" uplink) invokes this function;
143 // forward each envelope into the result queue for the test to observe.
144 const { result } = yield* ResultQueue;
145 const sink = yield* AWS.SQS.QueueSink(result);
146 yield* IoTWireless.consumeUplinks(destination, (uplinks) =>
147 uplinks.pipe(
148 Stream.map((uplink) => ({ MessageBody: JSON.stringify(uplink) })),
149 Stream.run(sink),
150 Effect.orDie,
151 ),
152 );
153 const resultQueueUrl = yield* result.queueUrl;
154
155 const decodeGeoJson = (
156 payload: Stream.Stream<Uint8Array, Error> | undefined,
157 ) =>
158 payload === undefined
159 ? Effect.succeed(undefined)
160 : Stream.mkString(Stream.decodeText(payload));
161
162 return {
163 fetch: Effect.gen(function* () {
164 const request = yield* HttpServerRequest;
165 const pathname = new URL(request.originalUrl).pathname;
166
167 // Cheap readiness route — no AWS call.
168 if (request.method === "GET" && pathname === "/ping") {
169 return yield* HttpServerResponse.json({
170 ok: true,
171 resultQueueUrl: yield* resultQueueUrl,
172 });
173 }
174
175 if (request.method === "POST" && pathname === "/send") {
176 const result = yield* sendData({
177 PayloadData: PAYLOAD,
178 TransmitMode: 1,
179 WirelessMetadata: { LoRaWAN: { FPort: 1 } },
180 });
181 return yield* HttpServerResponse.json({
182 messageId: result.MessageId ?? null,
183 });
184 }
185
186 if (request.method === "POST" && pathname === "/queued") {
187 const result = yield* listQueued();
188 const messages = result.DownlinkQueueMessagesList ?? [];
189 return yield* HttpServerResponse.json({
190 count: messages.length,
191 messageIds: messages.map((m) => m.MessageId ?? null),
192 });
193 }
194
195 if (request.method === "POST" && pathname === "/purge") {
196 // "*" purges every queued downlink message.
197 yield* deleteQueued({ MessageId: "*" });
198 return yield* HttpServerResponse.json({ ok: true });
199 }
200
201 if (request.method === "POST" && pathname === "/device-stats") {
202 const stats = yield* getDeviceStats();
203 return yield* HttpServerResponse.json({
204 wirelessDeviceId: stats.WirelessDeviceId ?? null,
205 lastUplinkReceivedAt: stats.LastUplinkReceivedAt ?? null,
206 });
207 }
208
209 if (request.method === "POST" && pathname === "/test-device") {
210 const result = yield* testDevice();
211 return yield* HttpServerResponse.json({
212 result: result.Result ?? null,
213 });
214 }
215
216 if (request.method === "POST" && pathname === "/gateway-stats") {
217 const stats = yield* getGatewayStats();
218 return yield* HttpServerResponse.json({
219 wirelessGatewayId: stats.WirelessGatewayId ?? null,
220 connectionStatus: stats.ConnectionStatus ?? null,
221 });
222 }
223
224 if (request.method === "POST" && pathname === "/service-endpoint") {
225 const endpoint = yield* getEndpoint({ ServiceType: "LNS" });
226 return yield* HttpServerResponse.json({
227 serviceType: endpoint.ServiceType ?? null,
228 serviceEndpoint: endpoint.ServiceEndpoint ?? null,
229 });
230 }
231
232 if (request.method === "POST" && pathname === "/update-position") {
233 // Coordinates are [longitude, latitude, altitude].
234 yield* updatePosition({
235 GeoJsonPayload: JSON.stringify({
236 type: "Point",
237 coordinates: [-122.33, 47.61, 10],
238 }),
239 });
240 return yield* HttpServerResponse.json({ ok: true });
241 }
242
243 if (request.method === "POST" && pathname === "/position") {
244 const result = yield* getPosition();
245 const geoJson = yield* decodeGeoJson(result.GeoJsonPayload);
246 return yield* HttpServerResponse.json({ geoJson: geoJson ?? null });
247 }
248
249 if (request.method === "POST" && pathname === "/position-estimate") {
250 // Fabricated WiFi scans exercise the solver path end-to-end; the
251 // solver may not resolve unknown MACs, so surface the typed tag
252 // instead of failing the route.
253 const result = yield* Effect.result(
254 estimatePosition({
255 WiFiAccessPoints: [
256 { MacAddress: "A0:EC:F9:1E:32:C1", Rss: -66 },
257 { MacAddress: "A0:EC:F9:15:72:5E", Rss: -72 },
258 ],
259 }),
260 );
261 if (Result.isSuccess(result)) {
262 const geoJson = yield* decodeGeoJson(result.success.GeoJsonPayload);
263 return yield* HttpServerResponse.json({
264 ok: true,
265 geoJson: geoJson ?? null,
266 });
267 }
268 return yield* HttpServerResponse.json({
269 ok: false,
270 tag: result.failure._tag,
271 });
272 }
273
274 return yield* HttpServerResponse.json(
275 { error: "Not found", method: request.method, pathname },
276 { status: 404 },
277 );
278 }).pipe(
279 // Surface typed API errors AND defects as diagnosable 500s (the
280 // test's post helper retries 5xx through IAM propagation).
281 Effect.catchCause((cause) =>
282 Effect.succeed(
283 HttpServerResponse.text(
284 `IoTWireless fixture error: ${String(Cause.squash(cause))}`,
285 { status: 500 },
286 ),
287 ),
288 ),
289 ),
290 };
291 }).pipe(
292 Effect.provide(
293 Layer.provideMerge(
294 Layer.mergeAll(
295 IoTWireless.SendDataToWirelessDeviceHttp,
296 IoTWireless.ListQueuedMessagesHttp,
297 IoTWireless.DeleteQueuedMessagesHttp,
298 IoTWireless.GetWirelessDeviceStatisticsHttp,
299 IoTWireless.TestWirelessDeviceHttp,
300 IoTWireless.GetResourcePositionHttp,
301 IoTWireless.UpdateResourcePositionHttp,
302 IoTWireless.GetWirelessGatewayStatisticsHttp,
303 IoTWireless.GetServiceEndpointHttp,
304 IoTWireless.GetPositionEstimateHttp,
305 Lambda.WirelessDestinationEventSource,
306 AWS.SQS.QueueSinkHttp,
307 ),
308 Layer.mergeAll(AWS.SQS.SendMessageBatchHttp, ResultQueueLive),
309 ),
310 ),
311 ),
312);