diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..baa0c7c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +dist +.env +.git +.gitignore +logs +*.log +npm-debug.log* +pnpm-debug.log* +README.md +SPEC.md +tasks +ecosystem.config.cjs +docker-compose.yml +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2f8d130 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# TagoIO Network token (Token header). Generate it under your Network's Tokens. +NETWORK_TOKEN=your-network-token-here + +# Authorization token (authorization_token query param) for the Network. +AUTHORIZATION_TOKEN=your-authorization-token-here + +# TagoIO API base URL. Change it to match your account region. +TAGO_API_URL=https://api.us-e1.tago.io + +# TCP port the middleware listens on for incoming device payloads. +PORT=3338 + +# Byte sequence that marks the end of one device message in the TCP stream. +# Use \n or \r\n for text protocols. Match what your device sends. +FRAME_DELIMITER=\n + +# Largest single frame (in bytes) buffered before the connection is dropped. +# Guards against a peer that never sends the delimiter. Default 65536. +MAX_FRAME_LENGTH=65536 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bebcf48 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Run Unit Tests + +on: + push: + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [24.x] + + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + - run: pnpm install + - run: pnpm run lint + - run: pnpm run test + - run: pnpm run build diff --git a/.gitignore b/.gitignore index fed9b8b..e12ef42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# Build output +dist + +# Environment +.env + # Logs logs *.log @@ -28,6 +34,7 @@ build/Release node_modules .settings .vscode +.claude # Elastic Beanstalk Files .elasticbeanstalk/* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02744df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 + +# --- Build stage: install all deps and compile TypeScript to dist/ --- +FROM node:24-slim AS build +WORKDIR /app +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY tsconfig.json ./ +COPY src ./src +RUN pnpm run build + +# --- Runtime stage: production deps only + compiled output --- +FROM node:24-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --prod && pnpm store prune + +COPY --from=build /app/dist ./dist + +# Run as the non-root user that the node image already provides. +USER node + +EXPOSE 3338 +CMD ["node", "dist/main.js"] diff --git a/README.md b/README.md index ce84600..8237b38 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,250 @@ -# Creating your Connector at TagoIO. +# TagoIO Middleware Example -First you need to have a connector created at your account on TagoIO. +A small TCP middleware that receives raw device payloads, reads the device serial +from the payload, and forwards the raw payload to TagoIO using the official +`@tago-io/sdk`. TagoIO routes the data to the matching device and decodes it with +the Connector's payload parser. For Class A devices, the middleware also delivers +a pending downlink back to the device on the same TCP connection. -1. Enter the connector page: https://admin.tago.io/connector -2. Create a new connector. -3. Setup a Name, a Short Description and a Category. Any other field is up to you. -4. Create the Middleware. -5. Refresh the page. As this page is experimental, you can have issues if not refresh after saving. -6. Go to the Tokens section page and generate a new TOKEN. -7. Copy the Token and replace the CONNECTOR-TOKEN in this code. +Use this repository as a starting point: clone it, adjust the payload handling +for your device, set your tokens, and run it with pnpm, pm2, or Docker. -The Connector Token give access to any device created using your Token. You can parent new connector to your connector in order to use the same token if you need different setups and don't need to have multiple middlewares. +## How it works -# How to Test it +1. The middleware listens for raw bytes on a TCP port and splits the stream into + complete frames on a delimiter (default newline). TCP is a byte stream, so it + buffers partial frames and splits batched ones (see `src/utils/framing.ts`). + A peer that never sends the delimiter would grow the buffer without bound, so + a frame larger than `MAX_FRAME_LENGTH` drops the connection. +2. Each complete frame is passed to `parseFrame` (see `src/utils/payload.ts`), + which returns the device `serial` (a string, any format) and the `value` to + send. The default expects a text frame `serial,value`. +3. It uses the SDK to resolve the serial into a device token + (`Network.resolveToken`) and sends `{ variable: "payload", value: "" }` + with `Device.sendData` (see `src/uplink/tagoio.ts`). +4. The Connector's payload parser decodes the hex `payload` value into the final + device variables (temperature, humidity, ...). +5. Right after the uplink, the middleware checks the device's Configuration + Parameters for a pending `downlink` and, if present, writes its bytes back on + the same socket (see `src/downlink/tagoio.ts`). -First you must create a Device using the connector you've just created. +The decoding lives on TagoIO, not in this code. This middleware frames the +stream, transports the payload, and relays downlinks. -1. Go to the Devices Page: https://admin.tago.io/devices/connectors -2. Enter the category you choosed for your connector. -3. Create the Device with a Serial Code you will use in your tests. +## TagoIO setup -Now you just need to get the middleware running and start sending data. +Do this on TagoIO before running the middleware. The order matters. -# How to setup this middleware. +### 1. Create the Network -This code uses Node.JS programming language. +Go to https://admin.tago.io/networks and create a Network with Serial Number enabled. -Open [Node.js Installation Guide](https://nodejs.org/en/download/package-manager/) for instructions on how to install NPM and Node.js. +See the documentation for reference: https://docs.tago.io/docs/tagoio/integrations/general/creating-a-network-integration -1. Open your favorite command-line tool like the Windows Command Prompt, PowerShell, Cygwin, Bash or the Git shell (which is installed along with Github for Windows). Then create or navigate to your new project folder. +### 2. Generate the Network token -2. Now you must install Tago SDK. Enter in your command-line npm install. This will start the installation of the TagoIO SDK. +Open the Network's **Tokens** section and generate a token. This is your +`NETWORK_TOKEN`. -3. Just run your middleware by using `npm start` or `node index.js` +You also need an **Authorization token** for the Network, used +to resolve the device serial (`AUTHORIZATION_TOKEN`). + +See the documentation for reference: https://docs.tago.io/docs/tagoio/integrations/general/authorization/ + +### 3. Create the Connector + +Create a Connector associated with the Network from step 1. + +Add the device payload parser to the **Connector**: this is what decodes the hex `payload` value +into your final device variables. A ready-to-paste example that decodes +temperature and humidity lives in [`examples/payload-parser.js`](examples/payload-parser.js). + +See the documentation: https://docs.tago.io/docs/tagoio/devices/payload-parser/ + +### 4. Create a device + +Create a device using your Connector, with the serial you will send in your +tests. The serial sent in the frame must match this device. + +## Configuration + +Copy the example env file and fill in your tokens: + +```bash +cp .env.example .env +``` + +| Variable | Required | Default | Description | +|-----------------------|----------|-----------------------------|------------------------------------------| +| `NETWORK_TOKEN` | yes | | Network token used by the SDK | +| `AUTHORIZATION_TOKEN` | yes | | Authorization token, used to resolve the serial | +| `TAGO_API_URL` | no | `https://api.us-e1.tago.io` | API base URL for your account region | +| `PORT` | no | `3338` | TCP port the middleware listens on | +| `FRAME_DELIMITER` | no | `\n` | Byte sequence marking the end of a frame | +| `MAX_FRAME_LENGTH` | no | `65536` | Max bytes buffered for one frame before the connection is dropped | + +The middleware fails to start if a required variable is missing. + +## Connector payload parser + +The middleware forwards the raw hex as a `payload` variable. The Connector +decodes it into device variables. [`examples/payload-parser.js`](examples/payload-parser.js) +is a working example that decodes two message types using this layout: + +| byte 0 (type) | bytes 1..2 (value) | variable | +|---------------|--------------------------------|---------------| +| `0x01` | signed int16 big-endian / 100 | `temperature` | +| `0x02` | signed int16 big-endian / 100 | `humidity` | + +Replace it with your device's real layout. The two test frames below match this +example so you can see the full path end to end. + +## Downlink (Class A) + +For Class A devices, TagoIO can only reach the device right after it sends an +uplink. The middleware reads the downlink from the device's Configuration +Parameters and writes it back on the same TCP connection. + +1. Open your device on TagoIO and go to the **Configuration Parameters** tab. +2. Create a parameter with key `downlink` and the hex payload as its value. +3. Leave the parameter **Unread** (`sent = false`). + +On the next uplink from that device, the middleware reads the `downlink` +parameter, writes its bytes back on the socket, logs +`Downlink sent to serial `, and marks the parameter as read so it is not +sent again. + +## Running + +Requires Node.js 24 or newer and pnpm. Install dependencies first: + +```bash +pnpm install +``` + +### Development + +Runs from TypeScript source and reloads on change: + +```bash +pnpm dev +``` + +### Production with Node + +Build to `dist/` and run the compiled output: + +```bash +pnpm build +pnpm start +``` + +### Production with pm2 + +pm2 keeps the middleware running unattended, restarting it on crash. Install +pm2 globally (with npm, which avoids pnpm's global bin PATH setup), build, then +start with the committed config: + +```bash +# Make sure pm2 is installed +pnpm build +pm2 start ecosystem.config.cjs +pm2 logs middleware-example +``` + +### Docker + +Build and run with environment from your `.env` file. Publish the same port the +app listens on, taking it from `PORT` in your `.env` (default `3338`) so the +mapping matches what the middleware binds inside the container: + +```bash +docker build -t middleware-example . +docker run --env-file .env -p "${PORT:-3338}:${PORT:-3338}" middleware-example +``` + +Or with Docker Compose: + +```bash +docker compose up --build +``` + +## Verifying it works + +1. Start the middleware with any of the methods above. You should see + `Middleware listening on port 3338`. +2. Send a test frame over TCP. The examples below use `nc` (netcat). It ships by + default on macOS, but many Linux distributions do not included. + + The default `parseFrame` expects `serial,value` ending with the delimiter + (`\n`). The frames below use serial `70-B3-D5-7E-D0-12-34-56`, so create a + device with that serial (or change it to match your device). + + Temperature (`010929` decodes to `23.45 C` with the example parser): + + ```bash + printf '70-B3-D5-7E-D0-12-34-56,010929\n' | nc localhost 3338 + ``` + + Humidity (`02162E` decodes to `56.78 %` with the example parser): + + ```bash + printf '70-B3-D5-7E-D0-12-34-56,02162E\n' | nc localhost 3338 + ``` + +3. On success the middleware logs `Data sent for serial 70-B3-D5-7E-D0-12-34-56`. +4. Open your device on TagoIO (https://admin.tago.io/devices) and check the data + tab. The raw payload arrives there and the Connector parser turns it into + `temperature` / `humidity` variables. +5. To test a downlink, add a `downlink` Configuration Parameter (see the section + above), then send another frame. The bytes are written back to the `nc` + session and the middleware logs `Downlink sent to serial ...`. + +### Troubleshooting + +- `Could not resolve device-token for serial ...`: the serial does not match any + device on the Network, or the `AUTHORIZATION_TOKEN` is wrong. Confirm the + device exists with that serial and uses the Connector tied to your Network. +- `Skipping frame without a serial`: `parseFrame` returned null (the default + needs a `serial,value` text frame). Check what your device sends, or adjust + `parseFrame`. +- `Connection closed with an unterminated frame (missing FRAME_DELIMITER)`: the + message arrived without the trailing delimiter, so it was never processed. This + is the usual cause when `printf` is missing the `\n`. Terminate each message + with `FRAME_DELIMITER`. +- `Dropping connection: ... Frame exceeded maxFrameLength`: a frame grew past + `MAX_FRAME_LENGTH` without a delimiter, so the connection was closed. Usually + the device is not sending `FRAME_DELIMITER`; confirm the delimiter matches, or + raise `MAX_FRAME_LENGTH` if your frames are legitimately larger. +- No data on the device: confirm the device serial matches and that the device + uses the Connector tied to your Network. +- Docker requests never reach the middleware: the published port must match + `PORT` from your `.env`. The app binds `PORT` inside the container, so + `docker run -p 3338:3338` only works when `PORT=3338`. Use + `-p "${PORT}:${PORT}"`, or `docker compose up` which already maps it for you. + +## Adapting to your device + +Two places control how raw bytes become a TagoIO payload: + +- **`src/utils/payload.ts` (`parseFrame`)**: the one function you edit. It takes + a complete frame and returns `{ serial, value }`. The default reads a text + frame `serial,value`, so the serial is a string in any format: a number, a hex + string, a MAC like `70-B3-D5-7E-D0-12-34-56`, an IMEI. For a binary protocol, + read the serial and value out of the bytes instead. Return `null` to skip a + frame. +- **`FRAME_DELIMITER`**: how the stream is split into frames. Set it to whatever + ends one message from your device. If your device uses length-prefixed frames + instead of a delimiter, replace `src/utils/framing.ts` with that logic. + +The rest of the flow (TCP server, forwarding to TagoIO, downlink relay) stays the +same. + +## Tests + +```bash +pnpm test +``` + +The tests cover the pure functions (serial extraction and frame splitting) in +`src/utils/`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4bb7909 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + middleware: + build: . + env_file: .env + ports: + - "${PORT:-3338}:${PORT:-3338}" + restart: unless-stopped diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..5bbf425 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,26 @@ +// pm2 process configuration. Run with: pm2 start ecosystem.config.cjs +// Build first (pnpm build) so dist/main.js exists. The app loads its +// environment from the .env file itself (via dotenv), so pm2 only needs to +// start it from the project directory. +module.exports = { + apps: [ + { + name: "middleware-example", + script: "dist/main.js", + instances: 1, + exec_mode: "fork", + + // Restart on crash, with backoff so a persistent failure does not loop hard. + autorestart: true, + max_restarts: 10, + restart_delay: 2000, + exp_backoff_restart_delay: 200, + + // Log handling: timestamped, combined, written under ./logs. + time: true, + merge_logs: true, + out_file: "logs/out.log", + error_file: "logs/error.log", + }, + ], +}; diff --git a/examples/payload-parser.js b/examples/payload-parser.js new file mode 100644 index 0000000..8e1ba8f --- /dev/null +++ b/examples/payload-parser.js @@ -0,0 +1,36 @@ +// TagoIO Connector payload parser (example). +// +// Paste this into your Connector's payload parser on TagoIO. It runs there, not +// in this middleware. The middleware forwards the raw hex as a `payload` +// variable; this parser decodes that hex into device variables. +// +// Example frame layout (replace with your device's real layout): +// byte 0 type 0x01 = temperature, 0x02 = humidity +// bytes 1..2 value signed int16, big-endian, scaled by 1/100 +// +// So "010929" -> type 0x01, value 0x0929 (2345) / 100 = 23.45 -> temperature +// "02162E" -> type 0x02, value 0x162E (5678) / 100 = 56.78 -> humidity + +const payloadItem = Array.isArray(payload) + ? payload.find((item) => item.variable === "payload") + : null; + +if (payloadItem && payloadItem.value) { + const bytes = Buffer.from(payloadItem.value, "hex"); + const type = bytes[0]; + const value = bytes.readInt16BE(1) / 100; + + if (type === 0x01) { + payload = payload.concat({ + variable: "temperature", + value, + unit: "\xB0C", + }); + } else if (type === 0x02) { + payload = payload.concat({ + variable: "humidity", + value, + unit: "%", + }); + } +} diff --git a/index.ts b/index.ts deleted file mode 100644 index 322b0e0..0000000 --- a/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Network, Device } from "@tago-io/sdk"; -import * as net from "net"; -const PORT = 3338; - -const network = new Network({ token: "" }); - -function parsePayload(payload: Buffer) { - - const data = [ - { - variable: "serial", - value: payload.readUInt32LE(1), - }, - { - variable: "timestamp", - value: payload.readUInt32LE(5), - }, - { - variable: "external_supply_voltage", - value: payload.readUInt8(9) + payload.readUInt8(10) * 0.01, - }, - { - variable: "supply_voltage", - value: payload.readUInt8(11) + payload.readUInt8(12) * 0.01, - }, - { - variable: "battery_voltage", - value: payload.readUInt8(13) + payload.readUInt8(14) * 0.01, - }, - { - variable: "temperature", - value: payload.readInt8(15) + payload.readUInt8(16) * 0.01, - }, - { - variable: "gsm_level", - value: payload.readInt8(17), - }, - ]; - - return data; -} - -async function dataReceived(msg: Buffer) { - const data = parsePayload(msg); - console.info("data ", data); - const serial = data.find((e) => e.variable == "serial")?.value.toString(); - - if (!serial) { - return console.log(`Serial not found`); - } - - const token = await network.resolveToken(serial).catch(() => null); - if (!token) { - return console.log(`Token not found, serie: ${serial}`); - } - - const device = new Device({ token }); - device.sendData(data).then(console.log, console.log); -} - -const server = net - .createServer((socket) => { - socket.on("data", dataReceived); - }) - .on("error", (err) => { - throw err; - }); - -server.listen(PORT); -console.info("Started Server at PORT", PORT); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index bea71a1..0000000 --- a/package-lock.json +++ /dev/null @@ -1,375 +0,0 @@ -{ - "name": "middleware-example", - "version": "1.0.0", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@tago-io/sdk": { - "version": "10.0.5", - "resolved": "https://registry.npmjs.org/@tago-io/sdk/-/sdk-10.0.5.tgz", - "integrity": "sha512-uTppKDNS1Z0KOj+lfTRnqvQ1iFKdPKIrS7h11czLUSjCfpe0apfRZcGWBlYYKH+LP7tvx+nXlEIuwcbLIJV2kQ==", - "requires": { - "axios": "0.20.0", - "form-data": "3.0.0", - "lodash.chunk": "4.2.0", - "qs": "6.9.4", - "socket.io-client": "2.3.0" - } - }, - "@types/node": { - "version": "14.11.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.2.tgz", - "integrity": "sha512-jiE3QIxJ8JLNcb1Ps6rDbysDhN4xa8DJJvuC9prr6w+1tIh+QAbYyNF3tyiZNLDBIuBCf4KEcV2UvQm/V60xfA==", - "dev": true - }, - "after": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", - "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=" - }, - "arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - }, - "arraybuffer.slice": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", - "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==" - }, - "async-limiter": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, - "axios": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz", - "integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==", - "requires": { - "follow-redirects": "^1.10.0" - } - }, - "backo2": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", - "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" - }, - "base64-arraybuffer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", - "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=" - }, - "better-assert": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", - "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=", - "requires": { - "callsite": "1.0.0" - } - }, - "blob": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", - "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==" - }, - "buffer-from": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", - "dev": true - }, - "callsite": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", - "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=" - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "component-bind": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", - "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=" - }, - "component-emitter": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", - "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" - }, - "component-inherit": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", - "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=" - }, - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "requires": { - "ms": "^2.1.1" - } - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" - }, - "diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true - }, - "engine.io-client": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.4.3.tgz", - "integrity": "sha512-0NGY+9hioejTEJCaSJZfWZLk4FPI9dN+1H1C4+wj2iuFba47UgZbJzfWs4aNFajnX/qAaYKbe2lLTfEEWzCmcw==", - "requires": { - "component-emitter": "~1.3.0", - "component-inherit": "0.0.3", - "debug": "~4.1.0", - "engine.io-parser": "~2.2.0", - "has-cors": "1.1.0", - "indexof": "0.0.1", - "parseqs": "0.0.5", - "parseuri": "0.0.5", - "ws": "~6.1.0", - "xmlhttprequest-ssl": "~1.5.4", - "yeast": "0.1.2" - }, - "dependencies": { - "component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" - } - } - }, - "engine.io-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.2.0.tgz", - "integrity": "sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==", - "requires": { - "after": "0.8.2", - "arraybuffer.slice": "~0.0.7", - "base64-arraybuffer": "0.1.5", - "blob": "0.0.5", - "has-binary2": "~1.0.2" - } - }, - "follow-redirects": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", - "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" - }, - "form-data": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz", - "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "has-binary2": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz", - "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", - "requires": { - "isarray": "2.0.1" - } - }, - "has-cors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", - "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=" - }, - "indexof": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", - "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" - }, - "isarray": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", - "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=" - }, - "lodash.chunk": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.chunk/-/lodash.chunk-4.2.0.tgz", - "integrity": "sha1-ZuXOH3btJ7QwPYxlEujRIW6BBrw=" - }, - "make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, - "mime-db": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", - "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" - }, - "mime-types": { - "version": "2.1.27", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", - "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", - "requires": { - "mime-db": "1.44.0" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "object-component": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz", - "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=" - }, - "parseqs": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", - "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", - "requires": { - "better-assert": "~1.0.0" - } - }, - "parseuri": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", - "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", - "requires": { - "better-assert": "~1.0.0" - } - }, - "qs": { - "version": "6.9.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", - "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" - }, - "socket.io-client": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.3.0.tgz", - "integrity": "sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==", - "requires": { - "backo2": "1.0.2", - "base64-arraybuffer": "0.1.5", - "component-bind": "1.0.0", - "component-emitter": "1.2.1", - "debug": "~4.1.0", - "engine.io-client": "~3.4.0", - "has-binary2": "~1.0.2", - "has-cors": "1.1.0", - "indexof": "0.0.1", - "object-component": "0.0.3", - "parseqs": "0.0.5", - "parseuri": "0.0.5", - "socket.io-parser": "~3.3.0", - "to-array": "0.1.4" - } - }, - "socket.io-parser": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.0.tgz", - "integrity": "sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==", - "requires": { - "component-emitter": "1.2.1", - "debug": "~3.1.0", - "isarray": "2.0.1" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - } - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "source-map-support": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", - "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "to-array": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", - "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=" - }, - "ts-node": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.0.0.tgz", - "integrity": "sha512-/TqB4SnererCDR/vb4S/QvSZvzQMJN8daAslg7MeaiHvD8rDZsSfXmNeNumyZZzMned72Xoq/isQljYSt8Ynfg==", - "dev": true, - "requires": { - "arg": "^4.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "source-map-support": "^0.5.17", - "yn": "3.1.1" - } - }, - "typescript": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.3.tgz", - "integrity": "sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg==" - }, - "ws": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz", - "integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==", - "requires": { - "async-limiter": "~1.0.0" - } - }, - "xmlhttprequest-ssl": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", - "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=" - }, - "yeast": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", - "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=" - }, - "yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true - } - } -} diff --git a/package.json b/package.json index dc04ed0..b982e40 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,33 @@ { "name": "middleware-example", - "version": "1.0.0", - "description": "First you need to have a connector created at your account on TagoIO.", - "main": "index.ts", + "version": "2.0.0", + "description": "Example TCP middleware that forwards raw device payloads to a TagoIO Network.", + "type": "module", + "main": "dist/main.js", + "packageManager": "pnpm@11.5.2", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "tsx watch src/main.ts", + "build": "tsc", + "start": "node dist/main.js", + "lint": "tsc --noEmit", + "test": "tsx --test src/**/*.test.ts" + }, "dependencies": { - "@tago-io/sdk": "^10.0.5", - "typescript": "^4.0.3" + "@tago-io/sdk": "^12.2.2", + "dotenv": "^17.4.2" }, "devDependencies": { - "@types/node": "^14.11.2", - "ts-node": "^9.0.0" - }, - "scripts": { - "start": "ts-node index.ts", - "test": "echo \"Error: no test specified\" && exit 1" + "@types/node": "^24.13.0", + "tsx": "^4.22.4", + "typescript": "^5.9.3" }, "repository": { "type": "git", "url": "git+https://github.com/tago-io/middleware-example.git" }, - "author": "", "license": "ISC", "bugs": { "url": "https://github.com/tago-io/middleware-example/issues" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..ceaf03f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,545 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tago-io/sdk': + specifier: ^12.2.2 + version: 12.2.2 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + devDependencies: + '@types/node': + specifier: ^24.13.0 + version: 24.13.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@tago-io/sdk@12.2.2': + resolution: {integrity: sha512-/QgQRCexcHqwMHIdgs4cMJx5RIAN0ABAhV/PbbaYvBYB6hCe9W4vBE7PT0qOkxkgIs98jwJygngSXYluACp1ZQ==} + engines: {node: '>=20.0.0'} + + '@types/node@24.13.1': + resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@4.0.0: + resolution: {integrity: sha512-fvIkb9qZzdMxgZrEQDyll+9oJsyaVvY92I2Re+qK0qEJ+w5s0X3dtz+M0VAPOjP1gtU3iqWyjQ0G3nvd5CLZ2g==} + engines: {node: '>=20.0.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@tago-io/sdk@12.2.2': + dependencies: + nanoid: 5.1.5 + papaparse: 5.5.3 + qs: 6.14.0 + optionalDependencies: + eventsource: 4.0.0 + + '@types/node@24.13.1': + dependencies: + undici-types: 7.18.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + eventsource-parser@3.1.0: + optional: true + + eventsource@4.0.0: + dependencies: + eventsource-parser: 3.1.0 + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + math-intrinsics@1.1.0: {} + + nanoid@5.1.5: {} + + object-inspect@1.13.4: {} + + papaparse@5.5.3: {} + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + tsx@4.22.4: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5b578b7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +allowBuilds: + esbuild: true +# esbuild ships a native binary built by a postinstall script. tsx depends on it +# for `pnpm dev` and `pnpm test`, so allow its build to run. +onlyBuiltDependencies: + - esbuild diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0b75bba --- /dev/null +++ b/src/config.ts @@ -0,0 +1,38 @@ +import "dotenv/config"; + +// Reads and validates configuration from the environment once at startup. +// Required tokens fail fast with a clear message so the server never starts in a +// state where every request would be rejected by TagoIO. + +/** + * Reads a required environment variable, throwing a clear error when it is + * missing so the server fails fast instead of running with rejected requests. + */ +const required = (name: string) => { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +}; + +// Frame delimiter that marks the end of one device message in the TCP stream. +// Written in .env as an escaped sequence (default "\n"); decode it to the real +// characters. Frames are text, so the splitter works on strings directly. +const frameDelimiter = (process.env.FRAME_DELIMITER ?? "\\n") + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\r"); + +const config = { + networkToken: required("NETWORK_TOKEN"), + authorizationToken: required("AUTHORIZATION_TOKEN"), + apiUrl: process.env.TAGO_API_URL ?? "https://api.us-e1.tago.io", + port: Number(process.env.PORT ?? 3338), + frameDelimiter, + // Cap on buffered bytes for a single frame. A device that never sends the + // delimiter would otherwise grow the buffer until the process runs out of + // memory; past this size the connection is dropped instead. + maxFrameLength: Number(process.env.MAX_FRAME_LENGTH ?? 65536), +}; + +export { config }; diff --git a/src/downlink/downink-class-a.ts b/src/downlink/downink-class-a.ts new file mode 100644 index 0000000..70bea86 --- /dev/null +++ b/src/downlink/downink-class-a.ts @@ -0,0 +1,29 @@ +import type { Device } from "@tago-io/sdk"; + +/** + * Class A downlink: a device only listens right after it sends an uplink, so the + * middleware checks for a pending downlink whenever a frame arrives and writes + * it back on the same TCP socket. + * + * On TagoIO the downlink lives in a Configuration Parameter with key `downlink`. + * `sent === false` ("Unread") means it has not been delivered yet. We read the + * hex value, mark the parameter as read so it is not sent again, and return the + * raw bytes for the caller to write to the socket. Returns null when there is + * nothing to send. + */ +async function getPendingDownlink(device: Device) { + const params = await device.getParameters("onlyUnRead").catch(() => []); + + const downlink = params.find( + (param) => param.key === "downlink" && !param.sent, + ); + if (!downlink?.value) { + return null; + } + + await device.setParameterAsRead(downlink.id).catch(() => null); + + return Buffer.from(downlink.value, "hex"); +} + +export { getPendingDownlink }; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..4284199 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,90 @@ +import net from "node:net"; + +import { config } from "./config.js"; +import { getPendingDownlink } from "./downlink/downink-class-a.js"; +import { parseFrame } from "./uplink/payload.js"; +import { sendToNetwork } from "./uplink/uplink.js"; +import { createFrameSplitter } from "./utils/framing.js"; + +/** + * Forwards one complete frame to TagoIO and, for Class A devices, writes any + * pending downlink back on the same socket. A frame without a usable serial is + * logged and skipped so a single bad message never takes the server down. + */ +async function handleFrame(frame: string, socket: net.Socket) { + const parsed = parseFrame(frame); + if (!parsed) { + // frame is device data: keep it out of the format-string position. + console.warn("Skipping frame without a serial: %s", frame); + return; + } + + const device = await sendToNetwork(parsed.serial, parsed.value); + if (!device) { + return; + } + + // Class A: the device is listening now (right after its uplink), so this is + // the moment to deliver a pending downlink. Write the raw bytes to the socket. + const downlink = await getPendingDownlink(device); + if (downlink) { + socket.write(downlink); + console.info( + "Downlink sent to serial %s (%d bytes)", + parsed.serial, + downlink.length, + ); + } +} + +const server = net.createServer((socket) => { + // One splitter per connection: TCP may fragment or batch messages, so we + // accumulate bytes and only act on complete, delimited frames. + const splitter = createFrameSplitter({ + delimiter: config.frameDelimiter, + maxFrameLength: config.maxFrameLength, + }); + + socket.on("data", (chunk) => { + let frames: string[]; + try { + frames = splitter.push(chunk); + } catch (error) { + // A frame grew past maxFrameLength without a delimiter: a misbehaving + // or malicious peer. Drop the connection so it can't exhaust memory. + console.warn("Dropping connection:", error); + socket.destroy(); + return; + } + + for (const frame of frames) { + handleFrame(frame, socket).catch((error) => + console.error("Failed to handle frame:", error), + ); + } + }); + + // If the connection closes while bytes are still buffered, the last message + // never ended with FRAME_DELIMITER, so it was never processed. Warn instead + // of dropping it silently, which otherwise looks like nothing happened. + socket.on("close", () => { + const unterminated = splitter.pending(); + if (unterminated) { + console.warn( + "Connection closed with an unterminated frame (missing FRAME_DELIMITER): %s", + unterminated, + ); + } + }); + + socket.on("error", (error) => console.error("Socket error:", error)); +}); + +server.on("error", (error) => { + console.error("Server error:", error); + process.exit(1); +}); + +server.listen(config.port, () => { + console.info(`Middleware listening on port ${config.port}`); +}); diff --git a/src/uplink/payload.test.ts b/src/uplink/payload.test.ts new file mode 100644 index 0000000..4b2876f --- /dev/null +++ b/src/uplink/payload.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { parseFrame } from "./payload.js"; + +test("parseFrame splits a text frame into serial and value", () => { + const result = parseFrame("70-B3-D5-7E-D0-12-34-56,41BC7E1F2A"); + + assert.deepEqual(result, { + serial: "70-B3-D5-7E-D0-12-34-56", + value: "41BC7E1F2A", + }); +}); + +test("parseFrame trims surrounding whitespace from serial and value", () => { + const result = parseFrame(" 10000 , 41BC7E1F2A "); + + assert.deepEqual(result, { serial: "10000", value: "41BC7E1F2A" }); +}); + +test("parseFrame returns null when the separator is missing", () => { + assert.equal(parseFrame("41BC7E1F2A"), null); +}); + +test("parseFrame returns null when the serial is empty", () => { + assert.equal(parseFrame(",41BC7E1F2A"), null); +}); + +test("parseFrame returns null when the value is empty", () => { + assert.equal(parseFrame("10000,"), null); +}); diff --git a/src/uplink/payload.ts b/src/uplink/payload.ts new file mode 100644 index 0000000..9e458de --- /dev/null +++ b/src/uplink/payload.ts @@ -0,0 +1,34 @@ +interface ParsedFrame { + serial: string; + value: string; +} + +const SEPARATOR = ","; + +/** + * Turns one complete device frame into the two values TagoIO needs: the device + * serial (a string, any format) and the payload to send as `value`. + * + * THIS IS THE FUNCTION YOU EDIT FOR YOUR DEVICE. The default expects a text + * frame "serial,value" (for example "70-B3-D5-7E-D0-12-34-56,41BC7E1F2A"), which + * keeps the serial in whatever format your device uses: a number, a hex string, + * a MAC, an IMEI. Change it to match your protocol, or return null to skip a + * frame that does not contain a usable serial and value. + */ +function parseFrame(frame: string): ParsedFrame | null { + const separatorIndex = frame.indexOf(SEPARATOR); + if (separatorIndex === -1) { + return null; + } + + const serial = frame.slice(0, separatorIndex).trim(); + const value = frame.slice(separatorIndex + 1).trim(); + + if (!serial || !value) { + return null; + } + + return { serial, value }; +} + +export { parseFrame }; diff --git a/src/uplink/uplink.ts b/src/uplink/uplink.ts new file mode 100644 index 0000000..539122c --- /dev/null +++ b/src/uplink/uplink.ts @@ -0,0 +1,57 @@ +import { Device, Network } from "@tago-io/sdk"; + +import { config } from "../config.js"; + +/** + * Resolves a device serial to a Device instance using the TagoIO SDK. The + * middleware holds a Network token, so it first exchanges the serial for a + * device token (Network.resolveToken), then builds a Device bound to it. + * Same pattern as the template-middleware-http get-device helper. + */ +async function _resolveDevice(serial: string) { + const network = new Network({ token: config.networkToken }); + + const token = await network + .resolveToken(serial, config.authorizationToken) + .catch((error: unknown) => { + // Pass serial as an argument instead of interpolating it: it comes from + // device data and must not land in the format-string position. + console.error( + "Could not resolve device-token for serial %s:", + serial, + error, + ); + return null; + }); + + if (!token) { + return null; + } + + return new Device({ token }); +} + +/** + * Sends one raw payload to TagoIO using the SDK. The payload stays as a hex + * string in the `payload` variable so the Connector's payload parser decodes it + * into device variables (temperature, humidity, ...). Returns the resolved + * Device so the caller can reuse it for a downlink without resolving the token + * a second time, or null when the serial does not match any device. + */ +async function sendToNetwork(serial: string, payloadHex: string) { + const device = await _resolveDevice(serial); + if (!device) { + return null; + } + + await device + .sendData({ variable: "payload", value: payloadHex }) + .then((result) => console.info("Data sent for serial %s:", serial, result)) + .catch((error: unknown) => + console.error("Send failed for serial %s:", serial, error), + ); + + return device; +} + +export { sendToNetwork }; diff --git a/src/utils/framing.test.ts b/src/utils/framing.test.ts new file mode 100644 index 0000000..fd92f89 --- /dev/null +++ b/src/utils/framing.test.ts @@ -0,0 +1,76 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createFrameSplitter } from "./framing.js"; + +const makeSplitter = (maxFrameLength = 65536) => + createFrameSplitter({ delimiter: "\n", maxFrameLength }); + +test("emits one frame for a single delimited chunk", () => { + const splitter = makeSplitter(); + + assert.deepEqual(splitter.push(Buffer.from("hello\n")), ["hello"]); +}); + +test("splits multiple frames contained in one chunk", () => { + const splitter = makeSplitter(); + + assert.deepEqual(splitter.push(Buffer.from("a\nb\nc\n")), ["a", "b", "c"]); +}); + +test("buffers a fragmented frame across chunks until the delimiter arrives", () => { + const splitter = makeSplitter(); + + assert.deepEqual(splitter.push(Buffer.from("hel")), []); + assert.deepEqual(splitter.push(Buffer.from("lo")), []); + assert.deepEqual(splitter.push(Buffer.from("\n")), ["hello"]); +}); + +test("keeps a trailing partial frame buffered without emitting it", () => { + const splitter = makeSplitter(); + + assert.deepEqual(splitter.push(Buffer.from("done\npartial")), ["done"]); +}); + +test("drops empty frames produced by back-to-back delimiters", () => { + const splitter = makeSplitter(); + + assert.deepEqual(splitter.push(Buffer.from("a\n\nb\n")), ["a", "b"]); +}); + +test("exposes an unterminated frame as pending", () => { + const splitter = makeSplitter(); + + splitter.push(Buffer.from("serial,010929")); + assert.equal(splitter.pending(), "serial,010929"); +}); + +test("clears pending once the delimiter arrives", () => { + const splitter = makeSplitter(); + + splitter.push(Buffer.from("serial,010929")); + splitter.push(Buffer.from("\n")); + assert.equal(splitter.pending(), ""); +}); + +test("throws when a pending frame grows past maxFrameLength", () => { + const splitter = makeSplitter(8); + + assert.throws( + () => splitter.push(Buffer.from("0123456789")), + /maxFrameLength/, + ); +}); + +test("clears the buffer after overflow so the next chunk starts clean", () => { + const splitter = makeSplitter(8); + + assert.throws(() => splitter.push(Buffer.from("0123456789"))); + assert.deepEqual(splitter.push(Buffer.from("ok\n")), ["ok"]); +}); + +test("does not throw when complete frames stay within maxFrameLength", () => { + const splitter = makeSplitter(4); + + assert.deepEqual(splitter.push(Buffer.from("abcd\nefgh\n")), ["abcd", "efgh"]); +}); diff --git a/src/utils/framing.ts b/src/utils/framing.ts new file mode 100644 index 0000000..655beae --- /dev/null +++ b/src/utils/framing.ts @@ -0,0 +1,61 @@ +/** + * TCP is a byte stream, not a message protocol: one "data" event may carry part + * of a message, a whole message, or several at once. createFrameSplitter keeps + * the bytes for a connection and hands back only the complete frames, split on a + * delimiter. The leftover (an unfinished frame) is kept for the next chunk. + * Create one splitter per socket. + * + * Frames are text, so chunks are decoded to strings here. For a binary protocol, + * split on Buffers instead. + */ +interface FrameSplitterOptions { + delimiter: string; + // Largest pending frame allowed before push() throws. Guards against a device + // that never sends the delimiter and would otherwise grow the buffer forever. + maxFrameLength: number; +} + +function createFrameSplitter({ + delimiter, + maxFrameLength, +}: FrameSplitterOptions) { + let leftover = ""; + + /** + * Adds a chunk and returns every complete frame now available. Throws when the + * pending (still unterminated) frame grows past maxFrameLength, so the caller + * can drop the connection instead of buffering without bound. + */ + function push(chunk: Buffer): string[] { + const parts = (leftover + chunk.toString()).split(delimiter); + + // The last part is whatever came after the final delimiter: an unfinished + // frame (or "" if the chunk ended exactly on a delimiter). Hold it back. + leftover = parts.pop() ?? ""; + + if (leftover.length > maxFrameLength) { + const overflow = leftover.length; + leftover = ""; + throw new Error( + `Frame exceeded maxFrameLength (${overflow} > ${maxFrameLength} bytes) without a delimiter`, + ); + } + + // Everything before it is complete. Drop empties from back-to-back delimiters. + return parts.filter((frame) => frame.length > 0); + } + + /** + * Returns the buffered bytes that arrived without a trailing delimiter, if + * any. A connection that closes with a non-empty pending frame sent a message + * that never ended with the delimiter. + */ + function pending(): string { + return leftover; + } + + return { push, pending }; +} + +export { createFrameSplitter }; +export type { FrameSplitterOptions }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ed037c7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +}