| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as IoTWireless from "@/AWS/IoTWireless"; |
| 3 | import * as Lambda from "@/AWS/Lambda"; |
| 4 | import * as Cause from "effect/Cause"; |
| 5 | import * as Context from "effect/Context"; |
| 6 | import * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Redacted from "effect/Redacted"; |
| 10 | import * as Result from "effect/Result"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 13 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 14 | import path from "pathe"; |
| 15 | |
| 16 | const 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. |
| 21 | const DEV_EUI = "1a2b3c4d5e6f7091"; |
| 22 | const DEV_ADDR = "01020304"; |
| 23 | const NWK_S_KEY = Redacted.make("000102030405060708090a0b0c0d0e0f"); |
| 24 | const APP_S_KEY = Redacted.make("0f0e0d0c0b0a09080706050403020100"); |
| 25 | const GATEWAY_EUI = "aa555a0000000201"; |
| 26 | |
| 27 | // base64("Hello, World!") |
| 28 | const PAYLOAD = "SGVsbG8sIFdvcmxkIQ=="; |
| 29 | |
| 30 | export 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. |
| 37 | export class ResultQueue extends Context.Service< |
| 38 | ResultQueue, |
| 39 | { result: AWS.SQS.Queue } |
| 40 | >()("IoTWirelessResultQueue") {} |
| 41 | |
| 42 | export const ResultQueueLive = Layer.effect( |
| 43 | ResultQueue, |
| 44 | Effect.gen(function* () { |
| 45 | const result = yield* AWS.SQS.Queue("IoTWirelessUplinkResultQueue"); |
| 46 | return { result }; |
| 47 | }), |
| 48 | ); |
| 49 | |
| 50 | export 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 | ); |