diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..46cff64 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run build + - run: npm test + + # ESLint 10 requires Node >= 20.19, above the package's runtime floor (18.17), + # so linting runs once on a modern Node instead of inside the matrix. + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c3ec43d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm run build + - run: npm test + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 688ef23..ab899ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -npm-debug.log -node_modules \ No newline at end of file +node_modules +dist +coverage +*.log diff --git a/.jscsrc b/.jscsrc deleted file mode 100644 index d6ad1a8..0000000 --- a/.jscsrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "preset": "airbnb", - "excludeFiles": ["node_modules/**", "bower_components/**"], - "maxErrors": 1000 -} diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..290ae08 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,103 @@ +# Migrating from v1 to v2 + +**TL;DR: you probably don't need to change anything.** v2 ships a full +compatibility layer: the v1 API (`gps.server(...)`, snake_case events, +`login_authorized(true)`, custom v1 adapters) keeps working unchanged. +The only hard requirement is **Node.js >= 18.17**. + +The v1 API is deprecated and will be removed in v3, so new code should use +the v2 API. Using any legacy entry point prints one `DeprecationWarning` +per process (silence it with `node --no-deprecation`). + +## What you get by migrating + +- TypeScript types for everything (events included). +- Proper TCP framing: positions no longer get lost/corrupted when packets + arrive fragmented or coalesced (v1 assumed 1 socket event = 1 packet). +- No more crashes on `ECONNRESET` (v1 issue #12). +- Idle connections are cleaned up automatically (v1 issue #31). +- Zero runtime dependencies (v1 needed `crc`, `crc16-ccitt-node`, + `node.extend` and an undeclared `crc-itu`). +- New protocols: SinoTrack ST-901 / H02 (`adapters.ST901`) and Concox + GK309 (`adapters.GK309`), plus correct CRC handling for GT06. + +## Side-by-side + +v1: + +```js +var gps = require('gps-tracking'); + +var server = gps.server({ port: 8090, device_adapter: 'TK103', debug: true }, function (device, connection) { + device.on('login_request', function (device_id, msg_parts) { + this.login_authorized(true); + }); + device.on('ping', function (data) { + console.log(data.latitude, data.longitude); + }); + device.on('alarm', function (alarm_code, alarm_data, msg_data) { + console.log(alarm_code); + }); +}); +``` + +v2: + +```ts +import { createServer, adapters } from 'gps-tracking'; + +const server = createServer({ port: 8090, adapter: adapters.TK103, logger: console }); + +server.on('connection', (device) => { + device.on('loginRequest', () => device.acceptLogin()); + device.on('ping', (position) => console.log(position.latitude, position.longitude)); + device.on('alarm', (alarm) => console.log(alarm.code, alarm.message)); +}); + +await server.listen(); +``` + +## API mapping + +| v1 (deprecated, still works) | v2 | +| ------------------------------------------ | ------------------------------------------------------ | +| `gps.server(opts, callback)` | `createServer(opts)` + `server.on('connection', ...)` | +| `device_adapter: 'TK103'` (string) | `adapter: adapters.TK103` (class) | +| `device_adapter: require('./my-adapter')` | subclass of `BaseAdapter` (see README) | +| `debug: true` | `logger: console` | +| auto-listen on creation | `await server.listen()` (explicit) | +| `device.on('login_request', cb)` | `device.on('loginRequest', cb)` | +| `device.login_authorized(true / false)` | `device.acceptLogin()` / `device.rejectLogin()` | +| `device.on('ping', (data) => ...)` | same event, typed `GpsPosition` payload | +| `device.on('alarm', (code, data, parts))` | `device.on('alarm', (alarm, packet))` | +| `device.getUID()` / `device.setUID()` | `device.id` (read-only) | +| `device.getName()` / `device.setName()` | `device.name` | +| `device.loged` | `device.isAuthenticated` | +| `device.set_refresh_time(i, d)` | `device.setRefreshInterval(i, d)` | +| `server.find_device(id)` | `server.getDevice(id)` | +| `server.send_to(id, msg)` | `server.sendTo(id, msg)` | +| `server.setDebug(v)` / `getDebug()` | `logger` option | +| `connection` callback argument | `device.socket` | + +## Behaviour changes to be aware of + +Even with the compat layer, a few things behave differently (for the better): + +- **GT06/GT02A device ids** no longer include the leading `0` of the + 16-digit BCD field: you now get the plain 15-digit IMEI + (`0123456789012345` → `123456789012345`). Update stored ids if needed. +- **TK510 dates** were read as `YYMMDD` in v1; the payload is RMC-style + `DDMMYY` and v2 parses it accordingly. TK510 speeds are now converted + from knots to km/h. +- **Position timestamps** are parsed as UTC `Date`s (v1 used the server's + local timezone). +- **Corrupt frames** no longer throw strings that could crash the process: + they emit a `parseError` event (or are discarded) and the connection + keeps working. +- **GT06 acks** now echo the device's real serial number and use the + correct CRC-16/X-25, so command responses work on real hardware + (v1 sent a hardcoded ack that only matched serial 1). +- **Custom v1 adapters** run through a passthrough framer that preserves + the v1 "1 socket event = 1 packet" behaviour, so they work exactly as + before — but they don't benefit from the TCP fragmentation fix. Port + them to `BaseAdapter` to get proper framing. diff --git a/README.md b/README.md new file mode 100644 index 0000000..23c5e42 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# GPS Tracking Server for Node.js + +[![CI](https://github.com/freshworkstudio/gps-tracking-nodejs/actions/workflows/ci.yml/badge.svg)](https://github.com/freshworkstudio/gps-tracking-nodejs/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/gps-tracking.svg)](https://www.npmjs.com/package/gps-tracking) +[![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE) + +Create TCP listeners for GPS tracking devices in a few lines. Written in +TypeScript, zero runtime dependencies, dual ESM/CJS, Node.js >= 18.17. + +```ts +import { createServer, adapters } from 'gps-tracking'; + +const server = createServer({ port: 8090, adapter: adapters.TK103 }); + +server.on('connection', (device) => { + device.on('loginRequest', () => device.acceptLogin()); + + device.on('ping', (position) => { + console.log(`${device.id}: ${position.latitude}, ${position.longitude} @ ${position.speed} km/h`); + }); + + device.on('alarm', (alarm) => { + console.log(`ALARM from ${device.id}: ${alarm.code} — ${alarm.message}`); + }); +}); + +await server.listen(); +``` + +> **Coming from v1?** Your code still works unchanged — the whole v1 API +> (`gps.server(...)`, snake_case events) ships as a deprecated compatibility +> layer. See [MIGRATION.md](MIGRATION.md). + +## Installation + +```bash +npm install gps-tracking +``` + +## Supported devices + +| Adapter | Protocol | Devices | +| ------------------ | -------- | --------------------------------------------------- | +| `adapters.TK103` | GPS103 | TK103 and clones | +| `adapters.GT06` | GT06 | Concox GT06, GT06N and clones | +| `adapters.GK309` | GT06 | Concox GK309, GK301, GK306 | +| `adapters.ST901` | H02 | SinoTrack ST-901 and other H02 devices (`adapters.H02` is an alias) | +| `adapters.GT02A` | GT02 | GT02A | +| `adapters.TK510` | TK510 | TK510 | + +Each server instance listens for **one** protocol. To support several device +models at once, run one server per protocol on different ports. + +## How it works + +1. `createServer({ port, adapter })` starts a TCP server for one protocol. +2. Every connection becomes a `Device` that emits typed events. +3. The adapter handles framing (TCP fragmentation included), parsing and + protocol acks (login, heartbeats, alarms) automatically. + +### Server options + +```ts +createServer({ + adapter: adapters.GT06, // required: adapter class + port: 8090, // default 8090 (0 = random free port) + host: '0.0.0.0', // optional bind address + connectionTimeoutMs: 120_000, // destroy idle connections (0 disables) + maxFrameLength: 4096, // discard corrupt/oversized frames + logger: console, // any {debug,info,warn,error}; default: silent +}); +``` + +### Server API + +```ts +const address = await server.listen(); // AddressInfo (address.port) +server.getDevice('865205035331981'); // Device | undefined +server.devices; // ReadonlyMap +server.sendTo('865205035331981', cmd); // boolean +await server.close(); +``` + +Events: `listening`, `connection`, `disconnect`, `error`, `close`. + +### Device API + +```ts +device.id; // IMEI / protocol id (undefined until identified) +device.isAuthenticated; // true after acceptLogin (or first packet for H02) +device.acceptLogin(); // accept a pending loginRequest +device.rejectLogin({ disconnect: true }); +device.send(data); // raw Buffer | string to the device +device.setRefreshInterval(30, 3600); // when the protocol supports it +device.disconnect(); +device.socket; // the underlying net.Socket +``` + +Events: `loginRequest`, `login`, `identified`, `ping`, `alarm`, `packet`, +`parseError`, `error`, `timeout`, `disconnect`. + +`ping` delivers a typed `GpsPosition`: + +```ts +interface GpsPosition { + latitude: number; + longitude: number; + time: Date; // UTC + valid?: boolean; // GPS fix validity + speed?: number; // km/h + orientation?: number; // degrees, north = 0 + mileage?: number; + satellites?: number; + extra?: Record; // protocol-specific (LBS, ignition, ...) +} +``` + +## Writing a custom adapter + +Extend `BaseAdapter`: choose a framer (how packets are delimited on the TCP +stream) and parse each frame into a `ParsedPacket`. + +```ts +import { BaseAdapter, DelimiterFramer, PacketParseError } from 'gps-tracking'; +import type { Framer, FramerOptions, ParsedPacket } from 'gps-tracking'; + +export class MyAdapter extends BaseAdapter { + static override readonly protocol = 'MYPROTO'; + static override readonly modelName = 'MY-DEVICE'; + + createFramer(options: FramerOptions): Framer { + // Also available: LengthPrefixedFramer for binary marker+length protocols. + return new DelimiterFramer({ start: 0x24, end: 0x0a, ...options }); // $ ... \n + } + + parsePacket(frame: Buffer): ParsedPacket { + const [id, cmd, payload] = frame.toString().slice(1, -1).split(','); + if (!id || !cmd) throw new PacketParseError('malformed frame'); + + if (cmd === 'LOGIN') return { cmd, raw: frame, action: 'loginRequest', deviceId: id }; + if (cmd === 'POS') { + return { + cmd, raw: frame, deviceId: id, action: 'ping', + position: { latitude: 1, longitude: 2, time: new Date() /* parse payload */ }, + }; + } + return { cmd, raw: frame, deviceId: id, action: 'other' }; + } + + authorize(): void { + this.device.send('$OK\n'); // login ack + } +} +``` + +Use it with `createServer({ adapter: MyAdapter, ... })`. Protocols without a +login handshake (like H02) can set `static override readonly requiresLogin = false`; +devices then authenticate automatically with their first valid packet. + +Optional hooks: `requestLogin`, `ackPing`, `ackAlarm`, `ackHeartbeat`, +`handleCommand`, `setRefreshInterval`. One adapter instance is created per +connection, so instance fields are safe for per-connection state. + +## GPS emulator + +To test without hardware, check the companion emulator: +[freshworkstudio/gps-tracking-emulator](https://github.com/freshworkstudio/gps-tracking-emulator) + +## Contributing + +```bash +npm install +npm test # vitest (unit + TCP integration) +npm run typecheck +npm run lint +npm run build # tsup → dist (ESM + CJS + types) +``` + +Protocol fixtures in `test/` come from official protocol documents +(Concox GK309 V1.8, HuaSunTeK H02 V1.0.5) and real captured packets — please +include fixtures with new adapters. + +## License + +[MIT](LICENSE) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f85d223 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,25 @@ +import { fileURLToPath, URL } from 'node:url'; +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'node_modules', 'examples/legacy-v1.cjs'] }, + js.configs.recommended, + tseslint.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: fileURLToPath(new URL('.', import.meta.url)), + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/consistent-type-imports': 'error', + }, + }, + { + files: ['eslint.config.js'], + extends: [tseslint.configs.disableTypeChecked], + }, +); diff --git a/examples/legacy-v1.cjs b/examples/legacy-v1.cjs new file mode 100644 index 0000000..deb61dd --- /dev/null +++ b/examples/legacy-v1.cjs @@ -0,0 +1,26 @@ +/** + * The gps-tracking v1 API, running unchanged on v2 through the compat layer. + * This is the v1 README example. New code should use examples/simple.ts instead. + */ +var gps = require('../dist/index.cjs'); + +var options = { + debug: true, + port: 8090, + device_adapter: 'TK103', +}; + +var server = gps.server(options, function (device, connection) { + device.on('login_request', function (device_id, msg_parts) { + // Accept the login request. You can set false to reject the device. + this.login_authorized(true); + }); + + // PING -> When the gps sends their position + device.on('ping', function (data) { + console.log(data); + return data; + }); +}); + +server.setDebug(true); diff --git a/examples/simple.js b/examples/simple.js deleted file mode 100644 index cedefc0..0000000 --- a/examples/simple.js +++ /dev/null @@ -1,32 +0,0 @@ -//var gps = require("gps-tracking"); -var gps = require('../index'); - -var options = { - debug: true, - port: 8090, - device_adapter: 'TK103B' -} - -var server = gps.server(options, function (device, connection) { - - device.on('login_request', function (device_id, msg_parts) { - // Some devices sends a login request before transmitting their position - // Do some stuff before authenticate the device... - - // Accept the login request. You can set false to reject the device. - this.login_authorized(true) - - }) - - //PING -> When the gps sends their position - device.on('ping', function (data) { - - //After the ping is received, but before the data is saved - //console.log(data); - return data - - }); - -}); - -server.setDebug(true); diff --git a/examples/simple.ts b/examples/simple.ts new file mode 100644 index 0000000..db17016 --- /dev/null +++ b/examples/simple.ts @@ -0,0 +1,29 @@ +/** + * Minimal v2 example: listen for TK103 devices on port 8090. + * Run with: npx tsx examples/simple.ts + */ +import { createServer, adapters } from '../src/index.js'; + +const server = createServer({ + port: 8090, + adapter: adapters.TK103, + logger: console, +}); + +server.on('connection', (device) => { + device.on('loginRequest', (deviceId) => { + console.log(`Device ${deviceId} wants to log in`); + device.acceptLogin(); // or device.rejectLogin({ disconnect: true }) + }); + + device.on('ping', (position) => { + console.log(`${device.id} is at ${position.latitude}, ${position.longitude} (${position.speed} km/h)`); + }); + + device.on('alarm', (alarm) => { + console.log(`ALARM from ${device.id}: ${alarm.code} — ${alarm.message}`); + }); +}); + +const address = await server.listen(); +console.log(`Listening on port ${address.port}`); diff --git a/index.js b/index.js deleted file mode 100644 index 063eb54..0000000 --- a/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./lib/server'); \ No newline at end of file diff --git a/lib/adapters/gt02a.js b/lib/adapters/gt02a.js deleted file mode 100644 index efaf276..0000000 --- a/lib/adapters/gt02a.js +++ /dev/null @@ -1,195 +0,0 @@ -/* Original code: https://github.com/cnberg/gps-tracking-nodejs/blob/master/lib/adapters/gt02a.js */ -f = require('../functions'); - -exports.protocol = 'GT02A'; -exports.model_name = 'GT02A'; -exports.compatible_hardware = ['GT02A/supplier']; - -var adapter = function (device) { - if (!(this instanceof adapter)) { - return new adapter(device); - } - - this.format = {'start': '(', 'end': ')', 'separator': ''}; - this.device = device; - this.__count = 1; - - /******************************************* - PARSE THE INCOMING STRING FROM THE DECIVE - You must return an object with a least: device_id, cmd and type. - return device_id: The device_id - return cmd: command from the device. - return type: login_request, ping, etc. - *******************************************/ - this.parse_data = function (data) { - data = this.bufferToHexString(data); - console.log(data); - var parts = { - 'start': data.substr(0, 4) - }; - - if (parts['start'] == '6868') { - parts['length'] = parseInt(data.substr(4, 2), 16); - parts['finish'] = data.substr(parts['length'] * 2 + 6, 4); - - if (parts['finish'] != '0d0a') { - throw 'finish code incorrect!'; - } - parts['power'] = parseInt(data.substr(6, 2), 16); - parts['gsm'] = parseInt(data.substr(8, 2), 16); - parts['device_id'] = data.substr(10, 16); - parts['count'] = data.substr(26, 4); - parts['protocal_id'] = data.substr(30, 2); - - parts['data'] = data.substr(32, parts['length']); - - if (parts['protocal_id'] == '1a') { - parts.cmd = 'login_request'; - parts.action = 'login_request'; - } else if (parts['protocal_id'] == '10') { - parts.cmd = 'ping'; - parts.action = 'ping'; - } else { - parts.cmd = 'noop'; - parts.action = 'noop'; - } - } else if (parts['start'] == '7979') { - parts['length'] = parseInt(data.substr(4, 4), 16); - parts['finish'] = data.substr(8 + parts['length'] * 2, 4); - - parts['protocal_id'] = data.substr(8, 2); - - if (parts['finish'] != '0d0a') { - throw 'finish code incorrect!'; - } - - if (parts['protocal_id'] == '94') { - parts['device_id'] = ''; - parts.cmd = 'noop'; - parts.action = 'noop'; - } - - } else if (parts['start'] == '7878') { - parts['length'] = parseInt(data.substr(4, 2), 16); - parts['finish'] = data.substr(6 + parts['length'] * 2, 4); - - parts['protocal_id'] = data.substr(6, 2); - - if (parts['finish'] != '0d0a') { - throw 'finish code incorrect!'; - } - - if (parts['protocal_id'] == '8a') { - parts['device_id'] = ''; - parts.cmd = 'clock'; - parts.action = 'clock'; - } else { - parts['device_id'] = ''; - parts.cmd = 'noop'; - parts.action = 'noop'; - } - } - return parts; - }; - this.bufferToHexString = function (buffer) { - var str = ''; - for (var i = 0; i < buffer.length; i++) { - if (buffer[i] < 16) { - str += '0'; - } - str += buffer[i].toString(16); - } - return str; - }; - this.authorize = function () { - this.send_comand('\u0054\u0068\u001a\u000d\u000a'); - }; - this.zeroPad = function (nNum, nPad) { - return ('' + (Math.pow(10, nPad) + nNum)).slice(1); - }; - this.synchronous_clock = function () { - var d = new Date(); - - var str = (d.getFullYear().toString().substr(2, 2)) + - (this.zeroPad(d.getMonth() + 1, 2).toString()) + - (this.zeroPad(d.getDate(), 2).toString()) + - (this.zeroPad(d.getHours(), 2).toString()) + - (this.zeroPad(d.getMinutes(), 2).toString()) + - (this.zeroPad(d.getSeconds(), 2).toString()) + - (this.zeroPad(this.__count, 4).toString()); - - this.__count++; - - var crc = require('/usr/lib/node_modules/crc/lib/index.js'); - var crcResult = f.str_pad(crc.crc16(str).toString(16), 4, '0'); - - var buff = new Buffer(str + crcResult, 'hex'); - this.send_comand('7878', buff); - }; - this.run_other = function (cmd, msg_parts) { - switch (cmd) { - case 'BP00': //Handshake - this.device.send(this.format_data(this.device.uid + 'AP01HSO')); - break; - } - }; - - this.request_login_to_device = function () { - //@TODO: Implement this. - }; - - this.receive_alarm = function (msg_parts) { - //@TODO: implement this - //My device have no support of this feature - return alarm; - }; - - this.dex_to_degrees = function (dex) { - return parseInt(dex, 16) / 1800000; - }; - - this.get_ping_data = function (msg_parts) { - var str = msg_parts.data; - - var data = { - 'date': str.substr(0, 12), - 'latitude': this.dex_to_degrees(str.substr(12, 8)), - 'longitude': this.dex_to_degrees(str.substr(20, 8)), - 'speed': parseInt(str.substr(28, 2), 16), - 'orientation': str.substr(30, 4), - }; - - res = { - latitude: data.latitude, - longitude: data.longitude, - speed: data.speed, - orientation: data.orientation - }; - return res; - }; - - /* SET REFRESH TIME */ - this.set_refresh_time = function (interval, duration) { - }; - - /* INTERNAL FUNCTIONS */ - - this.send_comand = function (cmd, data) { - var msg = [cmd, data]; - this.device.send(this.format_data(msg)); - }; - this.format_data = function (params) { - /* FORMAT THE DATA TO BE SENT */ - var str = this.format.start; - if (typeof(params) == 'string') { - str += params; - } else if (params instanceof Array) { - str += params.join(this.format.separator); - } else { - throw 'The parameters to send to the device has to be a string or an array'; - } - str += this.format.end; - return str; - }; -}; -exports.adapter = adapter; \ No newline at end of file diff --git a/lib/adapters/gt06.js b/lib/adapters/gt06.js deleted file mode 100644 index a7cd050..0000000 --- a/lib/adapters/gt06.js +++ /dev/null @@ -1,181 +0,0 @@ -/* Original code: https://github.com/cnberg/gps-tracking-nodejs/blob/master/lib/adapters/gt06.js */ -f = require('../functions'); -crc = require('crc'); - -exports.protocol = 'GT06'; -exports.model_name = 'GT06'; -exports.compatible_hardware = ['GT06/supplier']; - -var adapter = function (device) { - if (!(this instanceof adapter)) { - return new adapter(device); - } - - this.format = {'start': '(', 'end': ')', 'separator': ''}; - this.device = device; - this.__count = 1; - - /******************************************* - PARSE THE INCOMING STRING FROM THE DECIVE - You must return an object with a least: device_id, cmd and type. - return device_id: The device_id - return cmd: command from the device. - return type: login_request, ping, etc. - *******************************************/ - this.parse_data = function (data) { - data = data.toString('hex'); - var parts = { - 'start': data.substr(0, 4) - }; - - if (parts['start'] == '7878') { - parts['length'] = parseInt(data.substr(4, 2), 16); - parts['finish'] = data.substr(6 + parts['length'] * 2, 4); - - parts['protocal_id'] = data.substr(6, 2); - - if (parts['finish'] != '0d0a') { - throw 'finish code incorrect!'; - } - - if (parts['protocal_id'] == '01') { - parts['device_id'] = data.substr(8, 16); - parts.cmd = 'login_request'; - parts.action = 'login_request'; - } else if (parts['protocal_id'] == '12') { - parts['device_id'] = ''; - parts['data'] = data.substr(8, parts['length'] * 2); - parts.cmd = 'ping'; - parts.action = 'ping'; - } else if (parts['protocal_id'] == '13') { - parts['device_id'] = ''; - parts.cmd = 'heartbeat'; - parts.action = 'heartbeat'; - } else if (parts['protocal_id'] == '16' || parts['protocal_id'] == '18') { - parts['device_id'] = ''; - parts['data'] = data.substr(8, parts['length'] * 2); - parts.cmd = 'alert'; - parts.action = 'alert'; - } else { - parts['device_id'] = ''; - parts.cmd = 'noop'; - parts.action = 'noop'; - } - } else { - parts['device_id'] = ''; - parts.cmd = 'noop'; - parts.action = 'noop'; - } - return parts; - }; - this.authorize = function () { - //this.device.send("\u0078\u0078\u0005\u0001\u0000\u0001\u00d9\u00dc\u000d\u000a"); - //return ; - var length = '05'; - var protocal_id = '01'; - var serial = f.str_pad(this.__count, 4, 0); - - var str = length + protocal_id + serial; - - this.__count++; - - var crcResult = f.str_pad(crc.crc16(str).toString(16), 4, '0'); - - var buff = new Buffer('7878' + str + crcResult + '0d0a', 'hex'); - var buff = new Buffer('787805010001d9dc0d0a', 'hex'); - //发送原始数据 - this.device.send(buff); - }; - this.zeroPad = function (nNum, nPad) { - return ('' + (Math.pow(10, nPad) + nNum)).slice(1); - }; - this.synchronous_clock = function (msg_parts) { - - }; - this.receive_heartbeat = function (msg_parts) { - var buff = new Buffer('787805130001d9dc0d0a', 'hex'); - this.device.send(buff); - }; - this.run_other = function (cmd, msg_parts) { - }; - - this.request_login_to_device = function () { - //@TODO: Implement this. - }; - - this.receive_alarm = function (msg_parts) { - console.log(msg_parts); - var str = msg_parts.data; - - var data = { - 'date': str.substr(0, 12), - 'set_count': str.substr(12, 2), - 'latitude_raw': str.substr(14, 8), - 'longitude_raw': str.substr(22, 8), - 'latitude': this.dex_to_degrees(str.substr(14, 8)), - 'longitude': this.dex_to_degrees(str.substr(22, 8)), - 'speed': parseInt(str.substr(30, 2), 16), - 'orientation': str.substr(32, 4), - 'lbs': str.substr(36, 18), - 'device_info': f.str_pad(parseInt(str.substr(54, 2)).toString(2), 8, 0), - 'power': str.substr(56, 2), - 'gsm': str.substr(58, 2), - 'alert': str.substr(60, 4), - }; - - data['power_status'] = data['device_info'][0]; - data['gps_status'] = data['device_info'][1]; - data['charge_status'] = data['device_info'][5]; - data['acc_status'] = data['device_info'][6]; - data['defence_status'] = data['device_info'][7]; - console.log('alert'); - console.log(data); - }; - - this.dex_to_degrees = function (dex) { - return parseInt(dex, 16) / 1800000; - }; - - this.get_ping_data = function (msg_parts) { - var str = msg_parts.data; - - var data = { - 'date': str.substr(0, 12), - 'set_count': str.substr(12, 2), - 'latitude_raw': str.substr(14, 8), - 'longitude_raw': str.substr(22, 8), - 'latitude': this.dex_to_degrees(str.substr(14, 8)), - 'longitude': this.dex_to_degrees(str.substr(22, 8)), - 'speed': parseInt(str.substr(30, 2), 16), - 'orientation': str.substr(32, 4), - 'lbs': str.substr(36, 16), - }; - - /* - "device_info" : f.str_pad(parseInt(str.substr(54,2)).toString(2), 8, 0), - "power" : str.substr(56,2), - "gsm" : str.substr(58,2), - "alert" : str.substr(60,4), - data['power_status'] = data['device_info'][0]; - data['gps_status'] = data['device_info'][1]; - data['charge_status'] = data['device_info'][5]; - data['acc_status']= data['device_info'][6]; - data['defence_status'] = data['device_info'][7]; - */ - - console.log(data); - - res = { - latitude: data.latitude, - longitude: data.longitude, - speed: data.speed, - orientation: data.orientation - }; - return res; - }; - - /* SET REFRESH TIME */ - this.set_refresh_time = function (interval, duration) { - }; -}; -exports.adapter = adapter; \ No newline at end of file diff --git a/lib/adapters/tk103.js b/lib/adapters/tk103.js deleted file mode 100644 index a9c2ede..0000000 --- a/lib/adapters/tk103.js +++ /dev/null @@ -1,159 +0,0 @@ -/* */ -f = require("../functions"); - -exports.protocol="GPS103"; -exports.model_name="TK103"; -exports.compatible_hardware=["TK103/supplier"]; - -var adapter = function(device){ - if(!(this instanceof adapter)) return new adapter(device); - - this.format = {"start":"(","end":")","separator":""} - this.device = device; - - /******************************************* - PARSE THE INCOMING STRING FROM THE DECIVE - You must return an object with a least: device_id, cmd and type. - return device_id: The device_id - return cmd: command from the device. - return type: login_request, ping, etc. - *******************************************/ - this.parse_data = function(data){ - data = data.toString(); - var cmd_start = data.indexOf("B"); //al the incomming messages has a cmd starting with 'B' - if(cmd_start > 13)throw "Device ID is longer than 12 chars!"; - var parts={ - "start" : data.substr(0,1), - "device_id" : data.substring(1,cmd_start),//mandatory - "cmd" : data.substr(cmd_start,4), //mandatory - "data" : data.substring(cmd_start+4,data.length-1), - "finish" : data.substr(data.length-1,1) - }; - switch(parts.cmd){ - case "BP05": - parts.action="login_request"; - break; - case "BR00": - parts.action="ping"; - break; - case "BO01": - parts.action="alarm"; - break; - default: - parts.action="other"; - } - - return parts; - } - this.authorize =function(){ - this.send_comand("AP05"); - } - this.run_other = function(cmd,msg_parts){ - switch(cmd){ - case "BP00": //Handshake - this.device.send(this.format_data(this.device.uid+"AP01HSO")); - break; - } - } - - this.request_login_to_device = function(){ - //@TODO: Implement this. - } - - this.receive_alarm = function(msg_parts){ - //@TODO: implement this - - //Maybe we can save the gps data too. - //gps_data = msg_parts.data.substr(1); - alarm_code = msg_parts.data.substr(0,1); - alarm = false; - switch(alarm_code.toString()){ - case "0": - alarm = {"code":"power_off","msg":"Vehicle Power Off"}; - break; - case "1": - alarm = {"code":"accident","msg":"The vehicle suffers an acciden"}; - break; - case "2": - alarm = {"code":"sos","msg":"Driver sends a S.O.S."}; - break; - case "3": - alarm = {"code":"alarming","msg":"The alarm of the vehicle is activated"}; - break; - case "4": - alarm = {"code":"low_speed","msg":"Vehicle is below the min speed setted"}; - break; - case "5": - alarm = {"code":"overspeed","msg":"Vehicle is over the max speed setted"}; - break; - case "6": - alarm = {"code":"gep_fence","msg":"Out of geo fence"}; - break; - } - this.send_comand("AS01",alarm_code.toString()); - return alarm - } - - - this.get_ping_data = function(msg_parts){ - var str = msg_parts.data; - var data = { - "date" : str.substr(0,6), - "availability" : str.substr(6,1), - "latitude" : functions.minute_to_decimal(parseFloat(str.substr(7,9)),str.substr(16,1)), - "longitude" : functions.minute_to_decimal(parseFloat(str.substr(17,9)),str.substr(27,1)), - "speed" : parseFloat(str.substr(28,5)), - "time" : str.substr(33,6), - "orientation" : str.substr(39,6), - "io_state" : str.substr(45,8), - "mile_post" : str.substr(53,1), - "mile_data" : parseInt(str.substr(54,8),16) - }; - var datetime = "20"+data.date.substr(0,2)+"/"+data.date.substr(2,2)+"/"+data.date.substr(4,2); - datetime += " "+data.time.substr(0,2)+":"+data.time.substr(2,2)+":"+data.time.substr(4,2) - data.datetime=new Date(datetime); - res = { - latitude : data.latitude, - longitude : data.longitude, - time : new Date(data.date+" "+data.time), - speed : data.speed, - orientation : data.orientation, - mileage : data.mile_data - } - return res; - } - - /* SET REFRESH TIME */ - this.set_refresh_time = function(interval,duration){ - //XXXXYYZZ - //XXXX Hex interval for each message in seconds - //YYZZ Total time for feedback - //YY Hex hours - //ZZ Hex minutes - var hours = parseInt(duration/3600); - var minutes = parseInt((duration-hours*3600)/60); - var time = f.str_pad(interval.toString(16),4,'0')+ f.str_pad(hours.toString(16),2,'0')+ f.str_pad(minutes.toString(16),2,'0') - this.send_comand("AR00",time); - } - - /* INTERNAL FUNCTIONS */ - - this.send_comand = function(cmd,data){ - var msg = [this.device.uid,cmd,data]; - this.device.send(this.format_data(msg)); - } - this.format_data = function(params){ - /* FORMAT THE DATA TO BE SENT */ - var str = this.format.start; - if(typeof(params) == "string"){ - str+=params - }else if(params instanceof Array){ - str += params.join(this.format.separator); - }else{ - throw "The parameters to send to the device has to be a string or an array"; - } - str+= this.format.end; - return str; - } -} -exports.adapter = adapter; diff --git a/lib/adapters/tk510.js b/lib/adapters/tk510.js deleted file mode 100644 index 3c08c36..0000000 --- a/lib/adapters/tk510.js +++ /dev/null @@ -1,161 +0,0 @@ -/* */ -var f = require('../functions'); -var crc = require('crc16-ccitt-node'); - -exports.protocol = 'GPSTK510'; -exports.model_name = 'TK510'; -exports.compatible_hardware = ['TK510/supplier']; - -var adapter = function (device) { - if (!(this instanceof adapter)) return new adapter(device); - - this.format = {'start': '(', 'end': ')', 'separator': ''}; - this.device = device; - - /******************************************* - PARSE THE INCOMING STRING FROM THE DECIVE - You must return an object with a least: device_id, cmd and type. - return device_id: The device_id - return cmd: command from the device. - return type: login_request, ping, etc. - *******************************************/ - this.parse_data = function (data) { - var parts = { - 'device_id': data.toString('hex').substr(8, 14).replace(/f*$/, ''),//mandatory - 'cmd': data.toString('hex').substr(22, 4), //mandatory - 'data': data.toString('hex').substr(26).slice(0, -8), - }; - this.device_id_complete = data.toString('hex').substr(8, 14); - switch (parts.cmd) { - case '5000': - parts.action = 'login_request'; - break; - case '9955': - parts.action = 'ping'; - break; - case '9999': - parts.action = 'alarm'; - break; - default: - parts.action = 'other'; - } - - return parts; - }; - this.authorize = function () { - this.send_comand('4000', '01'); - }; - this.run_other = function (cmd, msg_parts) { - switch (cmd) { - case 'BP00': //Handshake - this.device.send(this.format_data(this.device.uid + 'AP01HSO')); - break; - } - }; - - this.request_login_to_device = function () { - //@TODO: Implement this. - }; - - this.receive_alarm = function (msg_parts) { - //@TODO: implement this - - //Maybe we can save the gps data too. - //gps_data = msg_parts.data.substr(1); - alarm_code = msg_parts.data.substr(0, 2); - alarm = {code: alarm_code, data: msg_parts.data.substr(2)}; - switch (alarm_code.toString()) { - case '01': - alarm = {'code': 'sos', 'msg': 'Driver sends a S.O.S.'}; - break; - case '50': - alarm = {'code': 'power_off', 'msg': 'Vehicle Power Off'}; - break; - case '71': - alarm = {'code': 'accident', 'msg': 'The vehicle suffers an acciden'}; - break; - case '05': - alarm = {'code': 'alarming', 'msg': 'The alarm of the vehicle is activated'}; - break; - case '11': - alarm = {'code': 'overspeed', 'msg': 'Vehicle is over the max speed setted'}; - break; - case '13': - alarm = {'code': 'gep_fence', 'msg': 'Out of geo fence'}; - break; - } - //this.send_comand("AS01",alarm_code.toString()); - return alarm; - }; - - this.get_ping_data = function (msg_parts) { - var data_parts = this.hex_to_ascii(msg_parts.data).split(','); - var data = { - 'time': data_parts[0], - 'gps_status': data_parts[1], - 'latitude_minutes': data_parts[2], - 'latitude_orientation': data_parts[3], - 'longitude_minutes': data_parts[4], - 'longitude_orientation': data_parts[5], - 'speed': data_parts[6], - 'orientation': data_parts[7], - 'date': data_parts[8], - 'magnetic_variation': data_parts[9], - 'direction': data_parts[10], - 'checksum': data_parts[11] - }; - var datetime = '20' + data.date.substr(0, 2) + '/' + data.date.substr(2, 2) + '/' + data.date.substr(4, 2); - datetime += ' ' + data.time.substr(0, 2) + ':' + data.time.substr(2, 2) + ':' + data.time.substr(4, 2); - data.datetime = new Date(datetime); - data.latitude = f.minute_to_decimal(data.latitude_minutes, data.latitude_orientation); - data.longitude = f.minute_to_decimal(data.longitude_minutes, data.longitude_orientation); - return data; - }; - - /* SET REFRESH TIME */ - this.set_refresh_time = function (interval, duration) { - //XXXXYYZZ - //XXXX Hex interval for each message in seconds - //YYZZ Total time for feedback - //YY Hex hours - //ZZ Hex minutes - var hours = parseInt(duration / 3600); - var minutes = parseInt((duration - hours * 3600) / 60); - var time = f.str_pad(interval.toString(16), 4, '0') + f.str_pad(hours.toString(16), 2, '0') + f.str_pad(minutes.toString(16), 2, '0'); - this.send_comand('AR00', time); - }; - - /* INTERNAL FUNCTIONS */ - - this.checksum = function (msg) { - return crc.getCrc16(new Buffer(msg, 'hex')).toString(16); - }; - - this.send_comand = function (cmd, data) { - if (typeof data === 'undefined') data = ''; - var l = data.length / 2 + 17; - var msg = '4040' + this.pad_hex(l.toString(16), 4) + this.device_id_complete + cmd.substr(0, 4) + data; - var checksum = this.checksum(msg); - msg += checksum + '0d0a'; - - var msg = new Buffer(msg, 'hex'); - this.device.send(msg); - }; - - this.pad_hex = function (string, length) { - var str = '' + string; - while (str.length < length) str = '0' + str; - return str; - }; - - this.hex_to_ascii = function (str1) { - var hex = str1.toString(); - var str = ''; - for (var n = 0; n < hex.length; n += 2) { - str += String.fromCharCode(parseInt(hex.substr(n, 2), 16)); - } - return str; - }; - -}; -exports.adapter = adapter; diff --git a/lib/device.js b/lib/device.js deleted file mode 100644 index d7ea787..0000000 --- a/lib/device.js +++ /dev/null @@ -1,182 +0,0 @@ -util = require('util'); -EventEmitter = require('events').EventEmitter; -util.inherits(Device, EventEmitter); - -function Device(adapter, connection, gpsServer) { - /* Inherits EventEmitter class */ - EventEmitter.call(this); - - var _this = this; - - this.connection = connection; - this.server = gpsServer; - this.adapter = adapter.adapter(this); - - this.uid = false; - this.ip = connection.ip; - this.port = connection.port; - this.name = false; - this.loged = false; - - init(); - /* init */ - function init() { - - } - - /**************************************** - RECEIVING DATA FROM THE DEVICE - ****************************************/ - this.on('data', function (data) { - var msgParts = _this.adapter.parse_data(data); - - if (this.getUID() === false && typeof (msgParts.device_id) === 'undefined') { - throw 'The adapter doesn\'t return the device_id and is not defined'; - } - - if (msgParts === false) { //something bad happened - _this.do_log('The message (' + data + ') can\'t be parsed. Discarding...'); - return; - } - - if (typeof (msgParts.cmd) === 'undefined') { - throw 'The adapter doesn\'t return the command (cmd) parameter'; - } - - //If the UID of the devices it hasn't been setted, do it now. - if (this.getUID() === false) { - this.setUID(msgParts.device_id); - } - - /************************************ - EXECUTE ACTION - ************************************/ - _this.make_action(msgParts.action, msgParts); - }); - - this.make_action = function (action, msgParts) { - //If we're not loged - if (action !== 'login_request' && !_this.loged) { - _this.adapter.request_login_to_device(); - _this.do_log(_this.getUID() + ' is trying to \'' + action + '\' but it isn\'t loged. Action wasn\'t executed'); - return false; - } - - switch (action) { - case 'login_request': - _this.login_request(msgParts); - break; - case 'ping': - _this.ping(msgParts); - break; - case 'alarm': - _this.receive_alarm(msgParts); - break; - case 'other': - _this.adapter.run_other(msgParts.cmd, msgParts); - break; - } - }; - - /**************************************** - LOGIN & LOGOUT - ****************************************/ - this.login_request = function (msgParts) { - _this.do_log('I\'m requesting to be loged.'); - _this.emit('login_request', this.getUID(), msgParts); - }; - - this.login_authorized = function (val, msgParts) { - if (val) { - this.do_log('Device ' + _this.getUID() + ' has been authorized. Welcome!'); - this.loged = true; - this.adapter.authorize(msgParts); - } else { - this.do_log('Device ' + _this.getUID() + ' not authorized. Login request rejected'); - } - }; - - this.logout = function () { - this.loged = false; - this.adapter.logout(); - }; - - /**************************************** - RECEIVING GPS POSITION FROM THE DEVICE - ****************************************/ - this.ping = function (msgParts) { - var gpsData = this.adapter.get_ping_data(msgParts); - if (gpsData === false) { - //Something bad happened - _this.do_log('GPS Data can\'t be parsed. Discarding packet...'); - return false; - } - - /* Needs: - latitude, longitude, time - Optionals: - orientation, speed, mileage, etc */ - - _this.do_log('Position received ( ' + gpsData.latitude + ',' + gpsData.longitude + ' )'); - gpsData.from_cmd = msgParts.cmd; - _this.emit('ping', gpsData, msgParts); - - }; - - /**************************************** - RECEIVING ALARM - ****************************************/ - this.receive_alarm = function (msgParts) { - //We pass the message parts to the adapter and they have to say wich type of alarm it is. - var alarmData = _this.adapter.receive_alarm(msgParts); - /* Alarm data must return an object with at least: - alarm_type: object with this format: - {'code':'sos_alarm','msg':'SOS Alarm activated by the driver'} - */ - _this.emit('alarm', alarmData.code, alarmData, msgParts); - }; - - /**************************************** - SET REFRESH TIME - ****************************************/ - this.set_refresh_time = function (interval, duration) { - _this.adapter.set_refresh_time(interval, duration); - }; - - /* adding methods to the adapter */ - this.adapter.get_device = function () { - return device; - }; - - this.send = function (msg) { - this.emit('send_data', msg); - this.connection.write(msg); - this.do_log('Sending to ' + _this.getUID() + ': ' + msg); - }; - - this.do_log = function (msg) { - _this.server.do_log(msg, _this.getUID()); - }; - - /**************************************** - SOME SETTERS & GETTERS - ****************************************/ - this.getName = function () { - return this.name; - }; - - this.setName = function (name) { - this.name = name; - }; - - this.getUID = function () { - return this.uid; - }; - - this.setUID = function (uid) { - this.uid = uid; - }; - -} - -module.exports = Device; diff --git a/lib/functions.js b/lib/functions.js deleted file mode 100644 index 95d5f4f..0000000 --- a/lib/functions.js +++ /dev/null @@ -1,127 +0,0 @@ -/***************************************** - FUNCTIONS - ******************************************/ -exports.rad = function (x) { - return x * Math.PI / 180; -}; - -/* - @param p1: {lat:X,lng:Y} - @param p2: {lat:X,lng:Y} - */ -exports.get_distance = function (p1, p2) { - var R = 6378137; // Earth’s mean radius in meter - var dLat = exports.rad(p2.lat - p1.lat); - var dLong = exports.rad(p2.lng - p1.lng); - var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(exports.rad(p1.lat)) * Math.cos(exports.rad(p2.lat)) * - Math.sin(dLong / 2) * Math.sin(dLong / 2); - var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - var d = R * c; - return d; // returns the distance in meter -}; - -exports.send = function (socket, msg) { - socket.write(msg); - console.log('Sending to ' + socket.name + ': ' + msg); -}; - -exports.parse_data = function (data) { - data = data.replace(/(\r\n|\n|\r)/gm, ''); //Remove 3 type of break lines - var cmd_start = data.indexOf('B'); //al the incomming messages has a cmd starting with 'B' - if (cmd_start > 13)throw 'Device ID is longer than 12 chars!'; - var parts = { - 'start': data.substr(0, 1), - 'device_id': data.substring(1, cmd_start), - 'cmd': data.substr(cmd_start, 4), - 'data': data.substring(cmd_start + 4, data.length - 1), - 'finish': data.substr(data.length - 1, 1) - }; - return parts; -}; - -exports.parse_gps_data = function (str) { - var data = { - 'date': str.substr(0, 6), - 'availability': str.substr(6, 1), - 'latitude': gps_minute_to_decimal(parseFloat(str.substr(7, 9))), - 'latitude_i': str.substr(16, 1), - 'longitude': gps_minute_to_decimal(parseFloat(str.substr(17, 9))), - 'longitude_i': str.substr(27, 1), - 'speed': str.substr(28, 5), - 'time': str.substr(33, 6), - 'orientation': str.substr(39, 6), - 'io_state': str.substr(45, 8), - 'mile_post': str.substr(53, 1), - 'mile_data': parseInt(str.substr(54, 8), 16) - }; - return data; -}; - -exports.send_to = function (socket, cmd, data) { - if (typeof(socket.device_id) == 'undefined')throw 'The socket is not paired with a device_id yet'; - var str = gps_format.start; - str += socket.device_id + gps_format.separator + cmd; - if (typeof(data) != 'undefined') str += gps_format.separator + data; - str += gps_format.end; - send(socket, str); - //Example: (||) - separator: | ,start: (, end: ) -}; - -exports.minute_to_decimal = function (pos, pos_i) { - if (typeof(pos_i) === 'undefined') pos_i = 'N'; - var dg = parseInt(pos / 100); - var minutes = pos - (dg * 100); - var res = (minutes / 60) + dg; - return (pos_i.toUpperCase() === 'S' || pos_i.toUpperCase() === 'W') ? res * -1 : res; -}; - -// Send a message to all clients -exports.broadcast = function (message, sender) { - clients.forEach(function (client) { - if (client === sender) return; - client.write(message); - }); - process.stdout.write(message + '\n'); -}; - -exports.data_to_hex_array = function (data) { - var arr = []; - for (var i = 0; i < data.length; i++)arr.push(data[i].toString(16)); - return arr; -}; - -/* RETRUN AN INTEGER FROM A HEX CHAR OR integer */ -exports.hex_to_int = function (hex_char) { - return parseInt(hex_char, 16); -}; - -exports.sum_hex_array = function (hex_array) { - var sum = 0; - for (var i in hex_array)sum += exports.hex_to_int(hex_array[i]); - return sum; -}; - -exports.hex_array_to_hex_str = function (hex_array) { - var str = ''; - for (var i in hex_array) { - var char; - if (typeof(hex_array[i]) === 'number') char = hex_array[i].toString(16); - else char = hex_array[i].toString(); - str += exports.str_pad(char, 2, '0'); - } - return str; -}; - -exports.str_pad = function (input, length, string) { - string = string || '0'; - input = input + ''; - return input.length >= length ? input : new Array(length - input.length + 1).join(string) + input; -}; - -exports.crc_itu_get_verification = function (hex_data) { - var crc16 = require('crc-itu').crc16; - if (typeof(hex_data) === 'String') str = hex_data; - else str = exports.hex_array_to_hex_str(hex_data); - return crc16(str, 'hex'); -}; diff --git a/lib/server.js b/lib/server.js deleted file mode 100644 index be7fd46..0000000 --- a/lib/server.js +++ /dev/null @@ -1,170 +0,0 @@ -util = require('util'); -EventEmitter = require('events').EventEmitter; -net = require('net'); -extend = require('node.extend'); -functions = require('./functions'); -Device = require('./device'); - -util.inherits(Server, EventEmitter); - -function Server(opts, callback) { - if (!(this instanceof Server)) { - return new Server(opts, callback); - } - - EventEmitter.call(this); - var defaults = { - debug: false, - port: 8080, - device_adapter: false, - }; - - //Merge default options with user options - this.opts = extend(defaults, opts); - - var _this = this; - this.devices = []; - - this.server = false; - this.availableAdapters = { - TK103: './adapters/tk103', - TK510: './adapters/tk510', - GT02A: './adapters/gt02a', - GT06: './adapters/gt06', - }; - - /**************************** - SOME FUNCTIONS - *****************************/ - /* */ - this.setAdapter = function (adapter) { - if (typeof adapter.adapter !== 'function') { - throw 'The adapter needs an adapter() method to start an instance of it'; - } - - this.device_adapter = adapter; - }; - - this.getAdapter = function () { - return this.device_adapter; - }; - - this.addAdaptar = function (model, Obj) { - this.availableAdapters.push(model); - }; - - this.init = function (cb) { - //Set debug - _this.setDebug(this.opts.debug); - - /***************************** - DEVICE ADAPTER INITIALIZATION - ******************************/ - if (_this.opts.device_adapter === false) - throw 'The app don\'t set the device_adapter to use. Which model is sending data to this server?'; - - if (typeof _this.opts.device_adapter === 'string') { - - //Check if the selected model has an available adapter registered - if (typeof this.availableAdapters[this.opts.device_adapter] === 'undefined') - throw 'The class adapter for ' + this.opts.device_adapter + ' doesn\'t exists'; - - //Get the adapter - var adapterFile = (this.availableAdapters[this.opts.device_adapter]); - - this.setAdapter(require(adapterFile)); - - } else { - //IF THE APP PASS THE ADEPTER DIRECTLY - _this.setAdapter(this.opts.device_adapter); - } - - _this.emit('before_init'); - if (typeof cb === 'function') cb(); - _this.emit('init'); - - /* FINAL INIT MESSAGE */ - console.log('\n=================================================\nGPS LISTENER running at port ' + _this.opts.port + '\nEXPECTING DEVICE MODEL: ' + _this.getAdapter().model_name + '\n=================================================\n'); - }; - - this.addAdaptar = function (model, Obj) { - this.adapters.push(model); - }; - - this.do_log = function (msg, from) { - //If debug is disabled, return false - if (this.getDebug() === false) return false; - - //If from parameter is not set, default is server. - if (typeof from === 'undefined') { - from = 'SERVER'; - } - - msg = '#' + from + ': ' + msg; - console.log(msg); - - }; - - /**************************************** - SOME SETTERS & GETTERS - ****************************************/ - this.setDebug = function (val) { - this.debug = (val === true); - }; - - this.getDebug = function () { - return this.debug; - }; - - //Init app - this.init(function () { - /************************************* - AFTER INITIALIZING THE APP... - *************************************/ - _this.server = net.createServer(function (connection) { - //Now we are listening! - - //We create an new device and give the an adapter to parse the incomming messages - connection.device = new Device(_this.getAdapter(), connection, _this); - _this.devices.push(connection); - - //Once we receive data... - connection.on('data', function (data) { - connection.device.emit('data', data); - }); - - // Remove the device from the list when it leaves - connection.on('end', function () { - _this.devices.splice(_this.devices.indexOf(connection), 1); - connection.device.emit('disconnected'); - }); - - callback(connection.device, connection); - - connection.device.emit('connected'); - }).listen(opts.port); - }); - - /* Search a device by ID */ - this.find_device = function (deviceId) { - for (var i in this.devices) { - var dev = this.devices[i].device; - if (dev.uid === deviceId) { - return dev; - } - } - - return false; - }; - - /* SEND A MESSAGE TO DEVICE ID X */ - this.send_to = function (deviceId, msg) { - var dev = this.find_device(deviceId); - dev.send(msg); - }; - - return this; -} - -exports.server = Server; -exports.version = require('../package').version; diff --git a/package-lock.json b/package-lock.json index 8ba7b5b..a11470b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,66 +1,3263 @@ { "name": "gps-tracking", - "version": "1.1.0", - "lockfileVersion": 1, + "version": "2.0.0-beta.1", + "lockfileVersion": 3, "requires": true, - "dependencies": { - "base64-js": { + "packages": { + "": { + "name": "gps-tracking", + "version": "2.0.0-beta.1", + "license": "MIT", + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^18.19.0", + "eslint": "^10.7.0", + "tsup": "^8.0.0", + "typescript": "^5.5.0", + "typescript-eslint": "^8.63.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", - "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "buffer": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.0.tgz", - "integrity": "sha512-Xpgy0IwHK2N01ncykXTy6FpCWuM+CJSHoPVBLyNqyrWxsedpLvwsYUhf0ME3WRFNUhos0dMamz9cOS/xRDtU5g==", - "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" } }, - "crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "requires": { - "buffer": "^5.1.0" + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "crc16-ccitt-node": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crc16-ccitt-node/-/crc16-ccitt-node-1.0.6.tgz", - "integrity": "sha512-f+nOcz4xOxesVRZbs8UzR18kwKORovMxKb2AJKh5f3HcJe9oaNQh7zOOH65IELXP2J9jeO6ND+xuHHIW3MZoKQ==" + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } }, - "function-bind": { + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" } }, - "ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } }, - "is": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/is/-/is-3.3.0.tgz", - "integrity": "sha512-nW24QBoPcFGGHJGUwnfpI7Yc5CdqWNdsyHQszVE/z2pKHXzh7FZ5GWhJqSyaQ9wMkQnsTx+kAI8bHlCX4tKdbg==" + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" }, - "node.extend": { + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/vite-node/node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-2.0.2.tgz", - "integrity": "sha512-pDT4Dchl94/+kkgdwyS2PauDFjZG0Hk0IcHIB+LkW27HLDtdoeMxHTxZh39DYbPP8UflWXWj9JcdDozF+YDOpQ==", - "requires": { - "has": "^1.0.3", - "is": "^3.2.1" + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } } } diff --git a/package.json b/package.json index 1f4c90d..d975e7d 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,71 @@ { "name": "gps-tracking", - "version": "1.1.0", - "description": "Let you work with some GPS trackers that connects through tcp.", - "main": "index.js", + "version": "2.0.0-beta.1", + "description": "TCP server for GPS trackers (TK103, GT06, GK309, SinoTrack ST-901/H02, GT02A, TK510). TypeScript, zero dependencies.", + "type": "module", + "engines": { + "node": ">=18.17" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist", + "MIGRATION.md" + ], + "sideEffects": false, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build": "tsup src/index.ts --format esm,cjs --dts --clean", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "prepublishOnly": "npm run typecheck && npm run lint && npm run build && npm test" }, "repository": { "type": "git", - "url": "https://github.com/freshworkstudio/gps-tracking-nodejs.git" + "url": "git+https://github.com/freshworkstudio/gps-tracking-nodejs.git" }, "keywords": [ "gps", "tracker", - "Tk103", - "car", - "gps" + "tk103", + "gt06", + "gk309", + "sinotrack", + "st-901", + "h02", + "gt02a", + "tk510", + "tcp", + "gps-server", + "tracking" ], - "author": "Gonzalo De-Spirito (http://www.freshworkstudio.com/)", + "author": "Gonzalo De-Spirito (https://www.freshworkstudio.com/)", "license": "MIT", "bugs": { "url": "https://github.com/freshworkstudio/gps-tracking-nodejs/issues" }, "homepage": "https://github.com/freshworkstudio/gps-tracking-nodejs", - "dependencies": { - "crc": "^3.5.0", - "crc16-ccitt-node": "^1.0.6", - "node.extend": "^2.0.1" + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^18.19.0", + "eslint": "^10.7.0", + "tsup": "^8.0.0", + "typescript": "^5.5.0", + "typescript-eslint": "^8.63.0", + "vitest": "^3.0.0" } } diff --git a/readme.md b/readme.md deleted file mode 100644 index 51f9e16..0000000 --- a/readme.md +++ /dev/null @@ -1,468 +0,0 @@ - -![NODE.JS GPS Tracker Server](https://user-images.githubusercontent.com/1103494/31578284-95673986-b0f4-11e7-81dd-2fefd3fb0478.jpg) -![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square) - -GPS TRACKING SERVER | Node.js -============== - -This package let you easily create listeners for your GPS tracking devices. You can add your custom implementations to handle more protocols. - -- [Installation](#Installation) -- [Usage](#usage) -- [Adapters](#adapters) -- [Examples](#examples) -- [GPS Emulator](#gps-emulator) - -# Installation -With package manager [npm](http://npmjs.org/): - - npm install gps-tracking - -#### Currently supported models -- TK103 -- TK510 -- GT06 -- GT02A -* You can add your own adapters easily as commented below - -# Usage -Once you have installed the package, you can use it like: - -``` javascript -var gps = require("gps-tracking"); - -var options = { - 'debug' : true, - 'port' : 8090, - 'device_adapter' : "TK103" -} - -var server = gps.server(options,function(device,connection){ - - device.on("login_request",function(device_id,msg_parts){ - - // Some devices sends a login request before transmitting their position - // Do some stuff before authenticate the device... - - // Accept the login request. You can set false to reject the device. - this.login_authorized(true); - - }); - - - //PING -> When the gps sends their position - device.on("ping",function(data){ - - //After the ping is received, but before the data is saved - //console.log(data); - return data; - - }); - -}); -``` - -### Step by step - -1) [Install Node](https://nodejs.org/) - -2) Create a folder for your project - -3) Copy the example code above in a .js file like server.js - -4) Install the package in the project folder -``` bash -cd /path/to/my/project -npm install gps-tracking -``` -5) Run your server -``` bash -node server.js -``` -# Overview -With this package you are going to create a tcp server that listens on a open port of your server/computer for a specific gps device model. -For example, you are going to listen on port 8090 for 'TK103 gps-trackers'. - -If you want to listen for different kind of trackers, you have to create another tcp server. You can do this in a different node.js program in the same server, but you have to listen in a different port. - -So, you can listen on port 8090 for TK103 devices and listen on 8091 for TK102 devices (or any gps-tracker you want) - -# Options -#### debug -Enables console.log messages. -``` javascript - "debug":false, -``` -#### port -The port to listen to. Where the packages of the device will arrive. -``` javascript - "port": 8080, -``` - -#### device_adapter -Which device adapter will be used to parse the incoming packets. -``` javascript - "device_adapter": false, - // If false, the server will throw an error. - // At the moment, the modules comes with only one adater: TK103. - "device_adapter": "TK103" - // You can create your own adapter. - - //FOR USING A CUSTOM DEVICE ADAPTER - "device_adapter": require("./my_custom_adapter") -``` - -# Events -Once you create a server, you can access to the connection and the device object connected. Both objects emits events you can listen on your app. -```javascript -var server = gps.server(options,function(device,connection){ - //conection = net.createServer(...) object - //device = Device object -} -``` - -#### connection events -Available events: -- end -- data -- close -- timeout -- drain - -You can [check the documentation of node.js net object here](http://nodejs.org/api/net.html#net_net_createserver_options_connectionlistener). - -``` javascript -//Example: -var server = gps.server(opts,function(device,connection){ - connection.on("data",function(res){ - //When raw data comes from the device - }); -}); -``` - -### Device object events -Every time something connects to the server, a net connection and a new device object will be created. -The Device object is your interface to send & receive packets/commands. - -``` javascript -var server = gps.server(opts, function(device, connection){ - /* Available device variables: - ---------------------------- - device.uid -> Set when the first packet is parsed - device.name -> You can set a custon name for this device. - device.ip -> IP of the device - device.port --> Device port - */ - - /****************************** - LOGIN - ******************************/ - device.on("login_request", function(device_id, msg_parts){ - //Do some stuff before authenticate the device... - // This way you can prevent from anyone to send their position without your consent - this.login_authorized(true); //Accept the login request. - }); - - device.on("login",function() { - console.log("Hi! i'm " + device.uid); - }); - - device.on("login_rejected",function(){ - - }); - - - /****************************** - PING - When the gps sends their position - ******************************/ - device.on("ping",function(data){ - //After the ping is received - //console.log(data); - console.log("I'm here now: " + gps_data.latitude + ", " + gps_data.longitude); - return data; - }); - - - /****************************** - ALARM - When the gps sends and alarm - ******************************/ - device.on("alarm", function(alarm_code, alarm_data, msg_data) { - console.log("Help! Something happend: " + alarm_code + " (" + alarm_data.msg + ")"); - //call_me(); - }); - - - /****************************** - MISC - ******************************/ - device.on("handshake",function(){ - - }); - -}); - -server.setDebug(true); -``` - -# Adapters -If you want to create a new adapter, you have to create and exports an adapter function. -You can base your new adapter on one of these nativaly supported adapters: https://github.com/freshworkstudio/gps-tracking-nodejs/tree/master/lib/adapters - -`youradapter.js` -```javascript -exports.protocol="GPS103"; -exports.model_name="TK103"; -exports.compatible_hardware=["TK103/supplier"]; - -var adapter = function(device){ - //Code that parses and respond to commands -} -exports.adapter = adapter; -``` -#### Functions you have to implement -##### function parse_data(data) -You receive the data and you have to return an object with: - -```javascript -return { - 'device_id': 'string', - // ID of the device. Mandatory - - 'cmd': 'string', - //'string' Represents what the device is trying to do. You can send some of the available commands or a custom string. Mandatory - - 'data': 'string' - //Aditional data in the packet. Mandatory -} -``` -#### Available commands (What the device is trying to do?) -``` javscript -'cmd':'login_request' // The device is trying to login. -'cmd':'alarm' // (login_request, ping, alarm) -'cmd':'ping' //The device is sending gps_data - -//Or send custom string -'cmd':'other_command' //You can catch this custom command in you app. -``` -Example: -```javascript - var adapter = function(device){ - function parse_data(data){ - // Example implementation - // - // Packet from device: - // #ID_DEVICE_XXX#TIME#LOG_ME_IN_PLEASE#MORE_DATA(GPS,LBS,ETC)# - - //Do some stuff... - return { - "device_id" : 'ID_DEVICE_XXX',//mandatory - "cmd" : 'login_request', //mandatory - "data" : 'MORE_DATA(GPS,LBS,ETC)' //Mandatory - - //optional parameters. Anything you want. - "optional_params": '', - "more_optional_parameters":'...', - } - } - } -``` - - -### Full example (device_adapter implementation) -This is the implementation for TK103. -Example data: - -#### Login request from TK103 -Packet: -(012341234123BP05000012341234123140607A3330.4288S07036.8518W019.2230104172.3900000000L00019C2C) - -So, -Start String = "(" -Device ID = "012341234123" -Command = "BP05" --> "login_request" -Custom Data = "000012341234123140607A3330.4288S07036.8518W019.2230104172.3900000000L00019C2C" -Finish String = ")" - -```javascript -/* */ - -/* */ -// some functions you could use like this -// f = require('gps-tracking/functions'). There are optionals -f = require("../functions"); - -exports.protocol="GPS103"; -exports.model_name="TK103"; -exports.compatible_hardware=["TK103/supplier"]; - -var adapter = function(device){ - if(!(this instanceof adapter)) return new adapter(device); - - this.format = {"start":"(","end":")","separator":""} - this.device = device; - - /******************************************* - PARSE THE INCOMING STRING FROM THE DECIVE - You must return an object with a least: device_id, cmd and type. - return device_id: The device_id - return cmd: command from the device. - return type: login_request, ping, etc. - *******************************************/ - this.parse_data = function(data){ - data = data.toString(); - var cmd_start = data.indexOf("B"); //al the incomming messages has a cmd starting with 'B' - if(cmd_start > 13)throw "Device ID is longer than 12 chars!"; - var parts={ - "start" : data.substr(0,1), - "device_id" : data.substring(1,cmd_start),//mandatory - "cmd" : data.substr(cmd_start,4), //mandatory - "data" : data.substring(cmd_start+4,data.length-1), - "finish" : data.substr(data.length-1,1) - }; - switch(parts.cmd){ - case "BP05": - parts.action="login_request"; - break; - case "BR00": - parts.action="ping"; - break; - case "BO01": - parts.action="alarm"; - break; - default: - parts.action="other"; - } - - return parts; - } - this.authorize =function(){ - this.send_comand("AP05"); - } - this.run_other = function(cmd,msg_parts){ - switch(cmd){ - case "BP00": //Handshake - this.device.send(this.format_data(this.device.uid+"AP01HSO")); - break; - } - } - - this.request_login_to_device = function(){ - //@TODO: Implement this. - } - - this.receive_alarm = function(msg_parts){ - //@TODO: implement this - - //gps_data = msg_parts.data.substr(1); - alarm_code = msg_parts.data.substr(0,1); - alarm = false; - switch(alarm_code.toString()){ - case "0": - alarm = {"code":"power_off","msg":"Vehicle Power Off"}; - break; - case "1": - alarm = {"code":"accident","msg":"The vehicle suffers an acciden"}; - break; - case "2": - alarm = {"code":"sos","msg":"Driver sends a S.O.S."}; - break; - case "3": - alarm = {"code":"alarming","msg":"The alarm of the vehicle is activated"}; - break; - case "4": - alarm = {"code":"low_speed","msg":"Vehicle is below the min speed setted"}; - break; - case "5": - alarm = {"code":"overspeed","msg":"Vehicle is over the max speed setted"}; - break; - case "6": - alarm = {"code":"gep_fence","msg":"Out of geo fence"}; - break; - } - this.send_comand("AS01",alarm_code.toString()); - return alarm - } - - - this.get_ping_data = function(msg_parts){ - var str = msg_parts.data; - var data = { - "date" : str.substr(0,6), - "availability" : str.substr(6,1), - "latitude" : functions.minute_to_decimal(parseFloat(str.substr(7,9)),str.substr(16,1)), - "longitude" : functions.minute_to_decimal(parseFloat(str.substr(17,9)),str.substr(27,1)), - "speed" : parseFloat(str.substr(28,5)), - "time" : str.substr(33,6), - "orientation" : str.substr(39,6), - "io_state" : str.substr(45,8), - "mile_post" : str.substr(53,1), - "mile_data" : parseInt(str.substr(54,8),16) - }; - var datetime = "20"+data.date.substr(0,2)+"/"+data.date.substr(2,2)+"/"+data.date.substr(4,2); - datetime += " "+data.time.substr(0,2)+":"+data.time.substr(2,2)+":"+data.time.substr(4,2) - data.datetime=new Date(datetime); - res = { - latitude : data.latitude, - longitude : data.longitude, - time : new Date(data.date+" "+data.time), - speed : data.speed, - orientation : data.orientation, - mileage : data.mile_data - } - return res; - } - - /* SET REFRESH TIME */ - this.set_refresh_time = function(interval,duration){ - //XXXXYYZZ - //XXXX Hex interval for each message in seconds - //YYZZ Total time for feedback - //YY Hex hours - //ZZ Hex minutes - var hours = parseInt(duration/3600); - var minutes = parseInt((duration-hours*3600)/60); - var time = f.str_pad(interval.toString(16),4,'0')+ f.str_pad(hours.toString(16),2,'0')+ f.str_pad(minutes.toString(16),2,'0') - this.send_comand("AR00",time); - } - - /* INTERNAL FUNCTIONS */ - - this.send_comand = function(cmd,data){ - var msg = [this.device.uid,cmd,data]; - this.device.send(this.format_data(msg)); - } - this.format_data = function(params){ - /* FORMAT THE DATA TO BE SENT */ - var str = this.format.start; - if(typeof(params) == "string"){ - str+=params - }else if(params instanceof Array){ - str += params.join(this.format.separator); - }else{ - throw "The parameters to send to the device has to be a string or an array"; - } - str+= this.format.end; - return str; - } -} -exports.adapter = adapter; - - -``` -# Examples -### DEMO SERVER APP -You can check a basic demo app [here](https://github.com/freshworkstudio/gps-tracking-demo) - -# GPS Emulator -We created a brand new gps emulator so you can start testing your app in a breeze. -You can check the code of the emulator in [this repo](https://github.com/freshworkstudio/gps-tracking-emulator). -[https://github.com/freshworkstudio/gps-tracking-emulator](https://github.com/freshworkstudio/gps-tracking-emulator) - - -#### Stay tuned - Contributions -We are adding support for multiple devices and protocols. -We highly appreciate your contributions to the project. -Please, just throw me an email at gonzalo@freshworkstudio.com if you have questions/suggestions. - -#### Why NodeJS? -NodeJS appears to be the perfect solution to receive the data for your multiple GPS devices thanks to the amazing performance an ease of use. Actually, it's extremely fast and it's easy to understand. diff --git a/src/adapters/base-adapter.ts b/src/adapters/base-adapter.ts new file mode 100644 index 0000000..03dd7b0 --- /dev/null +++ b/src/adapters/base-adapter.ts @@ -0,0 +1,70 @@ +import type { Device } from '../device.js'; +import type { Framer, FramerOptions } from '../framing/framer.js'; +import type { ParsedPacket } from '../types.js'; +import { NotSupportedError } from '../errors.js'; + +/** + * Contract for a protocol adapter. One instance is created per connection, + * so instances may hold per-connection state (serial counters, cached ids...). + */ +export abstract class BaseAdapter { + /** Protocol name, e.g. 'GPS103'. */ + static readonly protocol: string = ''; + /** Device model this adapter targets, e.g. 'TK103'. */ + static readonly modelName: string = ''; + static readonly compatibleHardware: readonly string[] = []; + /** + * Whether the protocol has a login handshake. When false (e.g. H02/ST-901), + * the device is authenticated automatically on its first valid packet. + */ + static readonly requiresLogin: boolean = true; + + constructor(protected readonly device: Device) {} + + /** + * Create the framer that splits the TCP stream into frames for this protocol. + * Infrastructure limits (maxFrameLength) are injected via `options`. + */ + abstract createFramer(options: FramerOptions): Framer; + + /** + * Parse one complete frame into a ParsedPacket. + * Throw PacketParseError for corrupt frames (bad CRC, malformed fields); + * the server emits it as a `parseError` event and keeps running. + */ + abstract parsePacket(frame: Buffer): ParsedPacket; + + /** Send the protocol's login acknowledgement. Called by `device.acceptLogin()`. */ + abstract authorize(packet: ParsedPacket): void; + + /** Ask the device to log in (sent when an unauthenticated device sends data). */ + requestLogin(): void {} + + /** Acknowledge a position packet, for protocols that require it. */ + ackPing(_packet: ParsedPacket): void {} + + /** Acknowledge an alarm packet. */ + ackAlarm(_packet: ParsedPacket): void {} + + /** Acknowledge a heartbeat packet. */ + ackHeartbeat(_packet: ParsedPacket): void {} + + /** Handle protocol-specific commands (action 'other'), e.g. handshakes. */ + handleCommand(_packet: ParsedPacket): void {} + + /** Configure the device reporting interval, when the protocol supports it. */ + setRefreshInterval(_intervalSeconds: number, _durationSeconds: number): void { + throw new NotSupportedError( + `${(this.constructor as typeof BaseAdapter).modelName || this.constructor.name} does not support setRefreshInterval`, + ); + } +} + +/** Constructor + static metadata shape expected by ServerOptions.adapter. */ +export interface AdapterClass { + new (device: Device): BaseAdapter; + readonly protocol: string; + readonly modelName: string; + readonly compatibleHardware?: readonly string[]; + readonly requiresLogin?: boolean; +} diff --git a/src/adapters/gt02a.ts b/src/adapters/gt02a.ts new file mode 100644 index 0000000..516a4fb --- /dev/null +++ b/src/adapters/gt02a.ts @@ -0,0 +1,94 @@ +import { BaseAdapter } from './base-adapter.js'; +import { LengthPrefixedFramer, type FrameMarker } from '../framing/length-prefixed-framer.js'; +import type { Framer, FramerOptions } from '../framing/framer.js'; +import { GT06_FRAME_MARKERS } from './gt06.js'; +import { PacketParseError } from '../errors.js'; +import { minutes30000ToDegrees } from '../lib/geo.js'; +import { bcdImei, binaryDate, lazyHexData } from '../lib/protocol.js'; +import type { GpsPosition, ParsedPacket } from '../types.js'; + +// GT02A frames plus the Concox 7878/7979 service frames some units also emit. +const GT02A_FRAME_MARKERS: readonly FrameMarker[] = [ + { + marker: Buffer.from([0x68, 0x68]), + lengthOffset: 2, + lengthBytes: 1, + totalLength: (len) => len + 5, + }, + ...GT06_FRAME_MARKERS, +]; + +/** + * GT02A binary protocol: + * `6868 | len | power | gsm | deviceId(8 BCD) | count(2) | proto | data | 0d0a` + * Login proto 0x1a, position proto 0x10. Login ack: `54 68 1a 0d 0a` ("Th" + 0x1a + CRLF). + */ +export class Gt02aAdapter extends BaseAdapter { + static override readonly protocol = 'GT02A'; + static override readonly modelName = 'GT02A'; + static override readonly compatibleHardware = ['GT02A/supplier'] as const; + + createFramer(options: FramerOptions): Framer { + return new LengthPrefixedFramer({ markers: GT02A_FRAME_MARKERS, ...options }); + } + + parsePacket(frame: Buffer): ParsedPacket { + if (frame.length < 5 || frame.readUInt16BE(frame.length - 2) !== 0x0d0a) { + throw new PacketParseError('GT02A: invalid frame tail'); + } + + if (frame.readUInt16BE(0) !== 0x6868) { + // 0x7878/0x7979 service frames (clock sync etc.): nothing to do. + return { cmd: frame.subarray(3, 4).toString('hex'), raw: frame, action: 'ignore' }; + } + if (frame.length < 18) { + throw new PacketParseError('GT02A: frame too short'); + } + + const deviceId = bcdImei(frame.subarray(5, 13)); + const proto = frame.readUInt8(15); + const data = frame.subarray(16, frame.length - 2); + const base = { + cmd: proto.toString(16).padStart(2, '0'), + deviceId, + raw: frame, + serial: frame.readUInt16BE(13), + }; + + let packet: ParsedPacket; + switch (proto) { + case 0x1a: + packet = { ...base, action: 'loginRequest', deviceId }; + break; + case 0x10: + packet = { ...base, action: 'ping', position: this.#parsePosition(data, frame) }; + break; + default: + packet = { ...base, action: 'other' }; + } + return lazyHexData(packet, data); + } + + authorize(): void { + this.device.send(Buffer.from([0x54, 0x68, 0x1a, 0x0d, 0x0a])); + } + + // date(6) + lat(4) + lon(4) + speed(1) + course(2) + #parsePosition(data: Buffer, frame: Buffer): GpsPosition { + if (data.length < 17) { + throw new PacketParseError('GT02A: position content too short'); + } + return { + // GT02A does not transmit hemisphere information; coordinates come unsigned. + latitude: minutes30000ToDegrees(data.readUInt32BE(6)), + longitude: minutes30000ToDegrees(data.readUInt32BE(10)), + time: binaryDate(data), + speed: data.readUInt8(14), + orientation: data.readUInt16BE(15), + extra: { + power: frame.readUInt8(3), + gsmSignal: frame.readUInt8(4), + }, + }; + } +} diff --git a/src/adapters/gt06.ts b/src/adapters/gt06.ts new file mode 100644 index 0000000..b64d6f9 --- /dev/null +++ b/src/adapters/gt06.ts @@ -0,0 +1,216 @@ +import { BaseAdapter } from './base-adapter.js'; +import { LengthPrefixedFramer, type FrameMarker } from '../framing/length-prefixed-framer.js'; +import type { Framer, FramerOptions } from '../framing/framer.js'; +import { PacketParseError } from '../errors.js'; +import { crc16X25 } from '../lib/crc.js'; +import { minutes30000ToDegrees } from '../lib/geo.js'; +import { bcdImei, binaryDate, lazyHexData } from '../lib/protocol.js'; +import type { Alarm, GpsPosition, ParsedPacket } from '../types.js'; + +const PROTO = { + LOGIN: 0x01, + GPS: 0x10, + LBS: 0x11, + GPS_LBS: 0x12, + STATUS: 0x13, + ALARM: 0x16, + ALARM_2: 0x18, + LBS_PHONE: 0x19, +} as const; + +/** Concox frame markers: `7878` (1-byte length) and extended `7979` (2-byte length). */ +export const GT06_FRAME_MARKERS: readonly FrameMarker[] = [ + { + marker: Buffer.from([0x78, 0x78]), + lengthOffset: 2, + lengthBytes: 1, + totalLength: (len) => len + 5, + }, + { + marker: Buffer.from([0x79, 0x79]), + lengthOffset: 2, + lengthBytes: 2, + totalLength: (len) => len + 6, + }, +]; + +// Alarm byte in the alarm/language field (Concox GT06 doc). +const ALARM_BYTE: Record = { + 0x01: { code: 'sos', message: 'SOS button pressed' }, + 0x02: { code: 'power_cut', message: 'Power was cut off' }, + 0x03: { code: 'vibration', message: 'Vibration/shock detected' }, + 0x04: { code: 'geofence_enter', message: 'Vehicle entered the geofence' }, + 0x05: { code: 'geofence_exit', message: 'Vehicle left the geofence' }, + 0x06: { code: 'overspeed', message: 'Vehicle is over the configured maximum speed' }, +}; + +// Alarm encoded in bits 5-3 of the terminal-info byte (GK309 protocol doc V1.8). +const TERMINAL_INFO_ALARM: Record = { + 0b010: { code: 'power_on', message: 'Terminal powered on' }, + 0b011: { code: 'low_battery', message: 'Battery is low' }, + 0b100: { code: 'sos', message: 'SOS button pressed' }, + 0b101: { code: 'geofence_enter', message: 'Vehicle entered the geofence' }, + 0b110: { code: 'geofence_exit', message: 'Vehicle left the geofence' }, + 0b111: { code: 'power_off', message: 'Terminal powered off' }, +}; + +/** + * Concox GT06 binary protocol (also GK309, GK301, GK306 and many clones): + * `7878 | len | proto | content | serial(2) | crc16-x25(2) | 0d0a` + * (extended frames use `7979` with a 2-byte length). + */ +export class Gt06Adapter extends BaseAdapter { + static override readonly protocol: string = 'GT06'; + static override readonly modelName: string = 'GT06'; + static override readonly compatibleHardware: readonly string[] = ['GT06/supplier', 'GK309', 'GK301', 'GK306']; + + createFramer(options: FramerOptions): Framer { + return new LengthPrefixedFramer({ markers: GT06_FRAME_MARKERS, ...options }); + } + + parsePacket(frame: Buffer): ParsedPacket { + if (frame.length < 10 || frame.readUInt16BE(frame.length - 2) !== 0x0d0a) { + throw new PacketParseError('GT06: invalid frame tail'); + } + const crc = frame.readUInt16BE(frame.length - 4); + const computed = crc16X25(frame.subarray(2, frame.length - 4)); + if (crc !== computed) { + throw new PacketParseError( + `GT06: CRC mismatch (got 0x${crc.toString(16)}, computed 0x${computed.toString(16)})`, + ); + } + + const proto = this.#proto(frame); + const content = frame.subarray((frame.readUInt16BE(0) === 0x7979 ? 4 : 3) + 1, frame.length - 6); + const serial = frame.readUInt16BE(frame.length - 6); + const base = { + cmd: proto.toString(16).padStart(2, '0'), + raw: frame, + serial, + }; + + let packet: ParsedPacket; + switch (proto) { + case PROTO.LOGIN: { + if (content.length < 8) { + throw new PacketParseError('GT06: login packet too short'); + } + packet = { ...base, action: 'loginRequest', deviceId: bcdImei(content.subarray(0, 8)) }; + break; + } + case PROTO.GPS: + case PROTO.GPS_LBS: + packet = { ...base, action: 'ping', position: this.#parsePosition(content) }; + break; + case PROTO.ALARM: + case PROTO.ALARM_2: + packet = { ...base, action: 'alarm', ...this.#parseAlarm(content) }; + break; + case PROTO.STATUS: + packet = { ...base, action: 'heartbeat' }; + break; + default: + packet = { ...base, action: 'other' }; + } + return lazyHexData(packet, content); + } + + authorize(packet: ParsedPacket): void { + this.#sendAck(PROTO.LOGIN, packet.serial ?? 0); + } + + override ackPing(packet: ParsedPacket): void { + this.#echoAck(packet); + } + + override ackAlarm(packet: ParsedPacket): void { + this.#echoAck(packet); + } + + override ackHeartbeat(packet: ParsedPacket): void { + this.#echoAck(packet); + } + + override handleCommand(packet: ParsedPacket): void { + const proto = this.#proto(packet.raw); + if (proto === PROTO.LBS || proto === PROTO.LBS_PHONE) { + this.#sendAck(proto, packet.serial ?? 0); + } + } + + #proto(frame: Buffer): number { + return frame.readUInt8(frame.readUInt16BE(0) === 0x7979 ? 4 : 3); + } + + /** Ack a packet by echoing its protocol number and serial. */ + #echoAck(packet: ParsedPacket): void { + this.#sendAck(this.#proto(packet.raw), packet.serial ?? 0); + } + + /** Generic 10-byte server response: 7878 05 0d0a */ + #sendAck(proto: number, serial: number): void { + const response = Buffer.alloc(10); + response.writeUInt16BE(0x7878, 0); + response.writeUInt8(0x05, 2); + response.writeUInt8(proto, 3); + response.writeUInt16BE(serial, 4); + response.writeUInt16BE(crc16X25(response.subarray(2, 6)), 6); + response.writeUInt16BE(0x0d0a, 8); + this.device.send(response); + } + + // datetime(6) + gpsInfo(1) + lat(4) + lon(4) + speed(1) + courseStatus(2) [+ LBS ...] + #parsePosition(content: Buffer): GpsPosition { + if (content.length < 18) { + throw new PacketParseError('GT06: position content too short'); + } + const courseStatus = content.readUInt16BE(16); + const north = (courseStatus & 0x0400) !== 0; + const west = (courseStatus & 0x0800) !== 0; + const latitude = minutes30000ToDegrees(content.readUInt32BE(7)); + const longitude = minutes30000ToDegrees(content.readUInt32BE(11)); + + return { + latitude: north ? latitude : -latitude, + longitude: west ? -longitude : longitude, + time: binaryDate(content), + valid: (courseStatus & 0x1000) !== 0, + speed: content.readUInt8(15), + orientation: courseStatus & 0x03ff, + satellites: content.readUInt8(6) & 0x0f, + }; + } + + // gps(18) + lbsLength(1) + mcc(2) + mnc(1) + lac(2) + cellId(3) + terminalInfo + voltage + gsm + alarmLang(2) + #parseAlarm(content: Buffer): { alarm: Alarm; position: GpsPosition } { + const position = this.#parsePosition(content); + if (content.length < 32) { + return { alarm: { code: 'alarm', message: 'Device alarm' }, position }; + } + const terminalInfo = content.readUInt8(27); + const alarmByte = content.readUInt8(30); + const alarm = + ALARM_BYTE[alarmByte] ?? + TERMINAL_INFO_ALARM[(terminalInfo >> 3) & 0b111] ?? + ({ code: `alarm_${alarmByte}`, message: `Unknown GT06 alarm 0x${alarmByte.toString(16)}` } satisfies Alarm); + position.extra = { + terminalInfo, + voltage: content.readUInt8(28), + gsmSignal: content.readUInt8(29), + mcc: content.readUInt16BE(19), + mnc: content.readUInt8(21), + lac: content.readUInt16BE(22), + cellId: content.readUIntBE(24, 3), + }; + return { alarm: { ...alarm, raw: alarmByte.toString(16).padStart(2, '0') }, position }; + } +} + +/** + * Concox GK309 (and GK301/GK306): same wire protocol as GT06. + * Kept as a named adapter so `adapters.GK309` works and shows the right model name. + */ +export class Gk309Adapter extends Gt06Adapter { + static override readonly modelName: string = 'GK309'; + static override readonly compatibleHardware: readonly string[] = ['GK309', 'GK301', 'GK306']; +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..c26f002 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,20 @@ +import { Tk103Adapter } from './tk103.js'; +import { Tk510Adapter } from './tk510.js'; +import { Gt06Adapter, Gk309Adapter } from './gt06.js'; +import { Gt02aAdapter } from './gt02a.js'; +import { St901Adapter } from './st901.js'; + +export { BaseAdapter, type AdapterClass } from './base-adapter.js'; +export { Tk103Adapter, Tk510Adapter, Gt06Adapter, Gk309Adapter, Gt02aAdapter, St901Adapter }; + +/** Built-in adapters, keyed by model name. */ +export const adapters = { + TK103: Tk103Adapter, + TK510: Tk510Adapter, + GT06: Gt06Adapter, + GT02A: Gt02aAdapter, + GK309: Gk309Adapter, + ST901: St901Adapter, + /** Alias: the ST-901 adapter implements the generic H02 protocol. */ + H02: St901Adapter, +} as const; diff --git a/src/adapters/st901.ts b/src/adapters/st901.ts new file mode 100644 index 0000000..ac1816e --- /dev/null +++ b/src/adapters/st901.ts @@ -0,0 +1,99 @@ +import { BaseAdapter } from './base-adapter.js'; +import { DelimiterFramer } from '../framing/delimiter-framer.js'; +import type { Framer, FramerOptions } from '../framing/framer.js'; +import { PacketParseError } from '../errors.js'; +import { lookupAlarm, parseRmcPosition } from '../lib/protocol.js'; +import type { Alarm, GpsPosition, ParsedPacket } from '../types.js'; + +const ALARMS: Record = { + '1': { code: 'sos', message: 'SOS button pressed', raw: '1' }, + '2': { code: 'low_battery', message: 'Battery is low', raw: '2' }, + '3': { code: 'geofence', message: 'Geofence alarm', raw: '3' }, +}; + +/** + * H02 text protocol, used by the SinoTrack ST-901 and many other devices: + * `*HQ,,,...#` + * + * There is no login handshake: the device id travels in every message + * (ST-901 sends the last 10 IMEI digits; other firmwares the full IMEI), + * so devices authenticate automatically on their first valid packet. + * + * Note: some ST-901 firmwares additionally emit a fixed-length binary packet + * starting with `$`. That variant is not supported by this adapter. + */ +export class St901Adapter extends BaseAdapter { + static override readonly protocol = 'H02'; + static override readonly modelName = 'ST-901'; + static override readonly compatibleHardware = ['SinoTrack ST-901', 'H02 family'] as const; + static override readonly requiresLogin = false; + + createFramer(options: FramerOptions): Framer { + return new DelimiterFramer({ start: 0x2a, end: 0x23, ...options }); // * # + } + + parsePacket(frame: Buffer): ParsedPacket { + const body = frame.toString('ascii').slice(1, -1); + const fields = body.split(','); + if (fields.length < 3) { + throw new PacketParseError(`H02: malformed frame: ${frame.toString('ascii')}`); + } + const deviceId = (fields[1] ?? '').trim(); + const cmd = (fields[2] ?? '').trim(); + // The payload is the original body after the third comma (no re-join needed). + const data = + fields.length > 3 + ? body.slice((fields[0]?.length ?? 0) + (fields[1]?.length ?? 0) + (fields[2]?.length ?? 0) + 3) + : ''; + const base = { cmd, deviceId, data, raw: frame }; + + switch (cmd) { + case 'V1': + return { ...base, action: 'ping', position: this.#parsePosition(fields) }; + case 'HTBT': + case 'XT': + case 'V0': + return { ...base, action: 'heartbeat' }; + case 'ALRM': { + const raw = (fields[3] ?? '').trim(); + return { ...base, action: 'alarm', alarm: lookupAlarm(ALARMS, raw, 'H02') }; + } + case 'V4': + // Echo/ack of a server command; nothing to do. + return { ...base, action: 'ignore' }; + default: + return { ...base, action: 'other' }; + } + } + + authorize(): void { + // H02 has no login handshake (requiresLogin = false); nothing to send. + } + + override ackHeartbeat(packet: ParsedPacket): void { + if (packet.cmd === 'HTBT') { + // Newer ST-901 firmwares require the heartbeat to be echoed back. + this.device.send(`*HQ,${packet.deviceId},HTBT#`); + } + } + + // V1 fields (after maker,id,cmd): RMC core + status(hex32) [, mcc, mnc, lac, cid ...] + #parsePosition(fields: string[]): GpsPosition { + const position = parseRmcPosition(fields, 3, 'H02'); + + const extra: Record = {}; + const status = (fields[12] ?? '').trim(); + if (status.length === 8) { + const statusValue = Number.parseInt(status, 16); + if (!Number.isNaN(statusValue)) { + extra.status = status; + extra.ignition = ((statusValue >>> 10) & 1) === 1; + } + } + if (fields.length > 13) { + extra.lbs = fields.slice(13).map((field) => field.trim()); + } + position.extra = extra; + return position; + } +} diff --git a/src/adapters/tk103.ts b/src/adapters/tk103.ts new file mode 100644 index 0000000..160f690 --- /dev/null +++ b/src/adapters/tk103.ts @@ -0,0 +1,124 @@ +import { BaseAdapter } from './base-adapter.js'; +import { DelimiterFramer } from '../framing/delimiter-framer.js'; +import type { Framer, FramerOptions } from '../framing/framer.js'; +import { PacketParseError } from '../errors.js'; +import { minuteToDecimal } from '../lib/geo.js'; +import { digits2, lookupAlarm, utcDate } from '../lib/protocol.js'; +import type { Alarm, GpsPosition, ParsedPacket } from '../types.js'; + +const ALARMS: Record = { + '0': { code: 'power_off', message: 'Vehicle power off', raw: '0' }, + '1': { code: 'accident', message: 'The vehicle suffered an accident', raw: '1' }, + '2': { code: 'sos', message: 'Driver sent an S.O.S.', raw: '2' }, + '3': { code: 'alarming', message: 'The vehicle alarm was activated', raw: '3' }, + '4': { code: 'low_speed', message: 'Vehicle is below the configured minimum speed', raw: '4' }, + '5': { code: 'overspeed', message: 'Vehicle is over the configured maximum speed', raw: '5' }, + '6': { code: 'geofence_exit', message: 'Vehicle left the geofence', raw: '6' }, +}; + +/** + * TK103 / GPS103 text protocol: `()`. + * Commands: BP05 login, BR00 position, BO01 alarm, BP00 handshake. + */ +export class Tk103Adapter extends BaseAdapter { + static override readonly protocol = 'GPS103'; + static override readonly modelName = 'TK103'; + static override readonly compatibleHardware = ['TK103/supplier'] as const; + + createFramer(options: FramerOptions): Framer { + return new DelimiterFramer({ start: 0x28, end: 0x29, ...options }); // ( ) + } + + parsePacket(frame: Buffer): ParsedPacket { + const body = frame.toString('ascii').slice(1, -1); + const cmdStart = body.indexOf('B'); + if (cmdStart < 0 || cmdStart > 12) { + throw new PacketParseError(`TK103: no command found or device id longer than 12 chars: ${body}`); + } + const deviceId = body.slice(0, cmdStart); + const cmd = body.slice(cmdStart, cmdStart + 4); + const data = body.slice(cmdStart + 4); + const base = { cmd, deviceId, data, raw: frame }; + + switch (cmd) { + case 'BP05': + return { ...base, action: 'loginRequest', deviceId }; + case 'BR00': + return { ...base, action: 'ping', position: this.#parsePosition(data) }; + case 'BO01': { + const alarm = lookupAlarm(ALARMS, data.slice(0, 1), 'TK103'); + // Alarm packets carry the same position payload after the alarm code. + let position: GpsPosition | undefined; + try { + position = this.#parsePosition(data.slice(1)); + } catch { + position = undefined; + } + return { ...base, action: 'alarm', alarm, position }; + } + default: + return { ...base, action: 'other' }; + } + } + + authorize(packet: ParsedPacket): void { + this.device.send(`(${packet.deviceId ?? this.device.id}AP05)`); + } + + override ackAlarm(packet: ParsedPacket): void { + if (packet.action === 'alarm' && packet.alarm.raw !== undefined) { + this.device.send(`(${this.device.id}AS01${packet.alarm.raw})`); + } + } + + override handleCommand(packet: ParsedPacket): void { + if (packet.cmd === 'BP00') { + // Handshake + this.device.send(`(${this.device.id}AP01HSO)`); + } + } + + override setRefreshInterval(intervalSeconds: number, durationSeconds: number): void { + // AR00 + XXXX (interval, hex seconds) + YY (hex hours) + ZZ (hex minutes) + const hours = Math.floor(durationSeconds / 3600); + const minutes = Math.floor((durationSeconds - hours * 3600) / 60); + const time = + intervalSeconds.toString(16).padStart(4, '0') + + hours.toString(16).padStart(2, '0') + + minutes.toString(16).padStart(2, '0'); + this.device.send(`(${this.device.id}AR00${time})`); + } + + // YYMMDD + A/V + DDMM.MMMM + N/S + DDDMM.MMMM + E/W + speed(5) + HHMMSS + course(6) + io(8) + L + mileage(hex 8) + #parsePosition(data: string): GpsPosition { + if (data.length < 45) { + throw new PacketParseError(`TK103: position payload too short: ${data}`); + } + const latitude = minuteToDecimal(Number.parseFloat(data.slice(7, 16)), data[16] ?? 'N'); + const longitude = minuteToDecimal(Number.parseFloat(data.slice(17, 27)), data[27] ?? 'E'); + if (Number.isNaN(latitude) || Number.isNaN(longitude)) { + throw new PacketParseError(`TK103: invalid coordinates in payload: ${data}`); + } + const position: GpsPosition = { + latitude, + longitude, + time: utcDate( + digits2(data, 0), + digits2(data, 2), + digits2(data, 4), + digits2(data, 33), + digits2(data, 35), + digits2(data, 37), + ), + valid: data[6] === 'A', + speed: Number.parseFloat(data.slice(28, 33)), + orientation: Number.parseFloat(data.slice(39, 45)), + extra: { ioState: data.slice(45, 53) }, + }; + if (data[53] === 'L') { + position.mileage = Number.parseInt(data.slice(54, 62), 16); + } + return position; + } + +} diff --git a/src/adapters/tk510.ts b/src/adapters/tk510.ts new file mode 100644 index 0000000..136ac57 --- /dev/null +++ b/src/adapters/tk510.ts @@ -0,0 +1,102 @@ +import { BaseAdapter } from './base-adapter.js'; +import { LengthPrefixedFramer, type FrameMarker } from '../framing/length-prefixed-framer.js'; +import type { Framer, FramerOptions } from '../framing/framer.js'; +import { PacketParseError } from '../errors.js'; +import { crc16X25 } from '../lib/crc.js'; +import { lazyHexData, lookupAlarm, parseRmcPosition } from '../lib/protocol.js'; +import type { Alarm, ParsedPacket } from '../types.js'; + +const TK510_FRAME_MARKERS: readonly FrameMarker[] = [ + { + marker: Buffer.from([0x40, 0x40]), + lengthOffset: 2, + lengthBytes: 2, + totalLength: (len) => len, // length field holds the total frame length + }, +]; + +const ALARMS: Record = { + '01': { code: 'sos', message: 'Driver sent an S.O.S.', raw: '01' }, + '05': { code: 'alarming', message: 'The vehicle alarm was activated', raw: '05' }, + '11': { code: 'overspeed', message: 'Vehicle is over the configured maximum speed', raw: '11' }, + '13': { code: 'geofence_exit', message: 'Vehicle left the geofence', raw: '13' }, + '50': { code: 'power_off', message: 'Vehicle power off', raw: '50' }, + '71': { code: 'accident', message: 'The vehicle suffered an accident', raw: '71' }, +}; + +/** + * TK510 binary-framed protocol: + * `4040 | totalLen(2) | deviceId(7 BCD, F-padded) | cmd(2) | data | crc16-x25(2) | 0d0a` + * Commands: 0x5000 login, 0x9955 position (RMC-like ASCII payload), 0x9999 alarm. + */ +export class Tk510Adapter extends BaseAdapter { + static override readonly protocol = 'GPSTK510'; + static override readonly modelName = 'TK510'; + static override readonly compatibleHardware = ['TK510/supplier'] as const; + + createFramer(options: FramerOptions): Framer { + return new LengthPrefixedFramer({ markers: TK510_FRAME_MARKERS, ...options }); + } + + parsePacket(frame: Buffer): ParsedPacket { + if (frame.length < 17 || frame.readUInt16BE(frame.length - 2) !== 0x0d0a) { + throw new PacketParseError('TK510: invalid frame'); + } + const crc = frame.readUInt16BE(frame.length - 4); + const computed = crc16X25(frame.subarray(0, frame.length - 4)); + if (crc !== computed) { + throw new PacketParseError( + `TK510: CRC mismatch (got 0x${crc.toString(16)}, computed 0x${computed.toString(16)})`, + ); + } + + const deviceId = frame.subarray(4, 11).toString('hex').replace(/f*$/, ''); + const cmd = frame.subarray(11, 13).toString('hex'); + const data = frame.subarray(13, frame.length - 4); + const base = { cmd, deviceId, raw: frame }; + + let packet: ParsedPacket; + switch (cmd) { + case '5000': + packet = { ...base, action: 'loginRequest', deviceId }; + break; + case '9955': { + // RMC-like payload with protocol extras after the standard fields. + const fields = data.toString('ascii').split(','); + const position = parseRmcPosition(fields, 0, 'TK510'); + position.extra = { + magneticVariation: fields[9] ?? '', + magneticVariationDirection: fields[10] ?? '', + }; + packet = { ...base, action: 'ping', position }; + break; + } + case '9999': { + if (data.length < 1) { + throw new PacketParseError('TK510: alarm payload too short'); + } + const raw = data.readUInt8(0).toString(16).padStart(2, '0'); + packet = { ...base, action: 'alarm', alarm: lookupAlarm(ALARMS, raw, 'TK510') }; + break; + } + default: + packet = { ...base, action: 'other' }; + } + return lazyHexData(packet, data); + } + + authorize(packet: ParsedPacket): void { + // Echo the F-padded id exactly as the device sent it in the login frame. + this.#sendCommand(packet.raw.subarray(4, 11).toString('hex'), '4000', '01'); + } + + #sendCommand(deviceIdRaw: string, cmd: string, dataHex: string = ''): void { + const body = Buffer.from( + '4040' + (dataHex.length / 2 + 17).toString(16).padStart(4, '0') + deviceIdRaw + cmd + dataHex, + 'hex', + ); + const crc = Buffer.alloc(2); + crc.writeUInt16BE(crc16X25(body), 0); + this.device.send(Buffer.concat([body, crc, Buffer.from([0x0d, 0x0a])])); + } +} diff --git a/src/compat.ts b/src/compat.ts new file mode 100644 index 0000000..8d7bc75 --- /dev/null +++ b/src/compat.ts @@ -0,0 +1,405 @@ +/** + * v1 compatibility layer. + * + * Everything in this file is deprecated: it exists so that code written for + * gps-tracking v1 keeps working unchanged on v2. See MIGRATION.md for the + * modern equivalents. The first use of any legacy API emits one + * DeprecationWarning per process (silence with `node --no-deprecation`). + */ +import { EventEmitter } from 'node:events'; +import type { Socket } from 'node:net'; +import { createServer, type GpsServer } from './server.js'; +import type { Device } from './device.js'; +import { BaseAdapter, type AdapterClass } from './adapters/base-adapter.js'; +import { adapters } from './adapters/index.js'; +import { PassthroughFramer, type Framer, type FramerOptions } from './framing/framer.js'; + +type LegacySocket = Socket & { device?: LegacyDevice }; +import { AdapterError, PacketParseError } from './errors.js'; +import type { Alarm, GpsPosition, ParsedPacket } from './types.js'; + +let warned = false; +function warnDeprecated(): void { + if (!warned) { + warned = true; + process.emitWarning( + 'The gps-tracking v1 API (gps.server(...), snake_case events) is deprecated and will be removed in v3. ' + + 'See https://github.com/freshworkstudio/gps-tracking-nodejs/blob/master/MIGRATION.md', + 'DeprecationWarning', + ); + } +} + +/** Shape of a v1 adapter module: `{ adapter, protocol, model_name, compatible_hardware }`. */ +export interface LegacyAdapterModule { + adapter: (device: LegacyAdapterDevice) => LegacyAdapterInstance; + protocol?: string; + model_name?: string; + compatible_hardware?: string[]; +} + +/** The `device` façade handed to v1 adapter instances. */ +export interface LegacyAdapterDevice { + uid: string | undefined; + send(data: Buffer | string): void; +} + +export interface LegacyAdapterInstance { + parse_data(data: Buffer): LegacyMsgParts | false; + authorize(msgParts?: LegacyMsgParts): void; + get_ping_data?(msgParts: LegacyMsgParts): Record | false; + receive_alarm?(msgParts: LegacyMsgParts): { code: string; msg?: string } | false; + run_other?(cmd: string, msgParts: LegacyMsgParts): void; + request_login_to_device?(): void; + set_refresh_time?(interval: number, duration: number): void; +} + +export interface LegacyMsgParts { + device_id?: string; + cmd: string; + action?: string; + data?: string; + [key: string]: unknown; +} + +export interface LegacyServerOptions { + debug?: boolean; + port?: number; + device_adapter?: string | LegacyAdapterModule | AdapterClass | false; + [key: string]: unknown; +} + +/** Wraps a v1 adapter module so it satisfies the v2 BaseAdapter contract. */ +function wrapLegacyAdapter(module: LegacyAdapterModule): AdapterClass { + if (typeof module.adapter !== 'function') { + throw new AdapterError('The adapter needs an adapter() method to start an instance of it'); + } + + class LegacyAdapterWrapper extends BaseAdapter { + static override readonly protocol = module.protocol ?? 'legacy'; + static override readonly modelName = module.model_name ?? 'legacy'; + static override readonly compatibleHardware = module.compatible_hardware ?? []; + + #instance: LegacyAdapterInstance; + #lastParts: LegacyMsgParts | undefined; + #pendingLoginParts: LegacyMsgParts | undefined; + + constructor(device: Device) { + super(device); + const facade: LegacyAdapterDevice = { + get uid() { + return device.id; + }, + send: (data) => device.send(data), + }; + // v1 adapters were factory functions that also worked with `new`. + this.#instance = module.adapter(facade); + } + + createFramer(_options: FramerOptions): Framer { + // v1 had no framing: one 'data' event was assumed to be one packet. + // Preserved for legacy adapters; built-in v2 adapters do frame properly. + return new PassthroughFramer(); + } + + parsePacket(frame: Buffer): ParsedPacket { + const parts = this.#instance.parse_data(frame); + if (parts === false) { + throw new PacketParseError('legacy adapter parse_data() returned false'); + } + if (typeof parts.cmd === 'undefined') { + throw new PacketParseError("The adapter doesn't return the command (cmd) parameter"); + } + this.#lastParts = parts; + const base = { + cmd: parts.cmd, + deviceId: parts.device_id, + data: typeof parts.data === 'string' ? parts.data : undefined, + raw: frame, + }; + + switch (parts.action) { + case 'login_request': + if (!parts.device_id) { + throw new PacketParseError("The adapter doesn't return the device_id"); + } + // Kept separately: frames parsed while the login is pending (e.g. a + // heartbeat before acceptLogin) must not become the authorize() payload. + this.#pendingLoginParts = parts; + return { ...base, action: 'loginRequest', deviceId: parts.device_id }; + case 'ping': { + const gps = this.#instance.get_ping_data?.(parts); + if (!gps) { + throw new PacketParseError("GPS Data can't be parsed"); + } + return { ...base, action: 'ping', position: legacyGpsToPosition(gps) }; + } + case 'alarm': { + const alarm = this.#instance.receive_alarm?.(parts); + if (!alarm) { + throw new PacketParseError("Alarm data can't be parsed"); + } + return { ...base, action: 'alarm', alarm: { code: alarm.code, message: alarm.msg ?? alarm.code } }; + } + default: + return { ...base, action: 'other' }; + } + } + + authorize(): void { + const loginParts = this.#pendingLoginParts; + this.#pendingLoginParts = undefined; + this.#instance.authorize(loginParts ?? this.#lastParts); + } + + override requestLogin(): void { + this.#instance.request_login_to_device?.(); + } + + override handleCommand(packet: ParsedPacket): void { + if (this.#lastParts) { + this.#instance.run_other?.(packet.cmd, this.#lastParts); + } + } + + override setRefreshInterval(intervalSeconds: number, durationSeconds: number): void { + this.#instance.set_refresh_time?.(intervalSeconds, durationSeconds); + } + } + + return LegacyAdapterWrapper; +} + +function legacyGpsToPosition(gps: Record): GpsPosition { + const { latitude, longitude, time, speed, orientation, mileage, ...extra } = gps; + return { + latitude: Number(latitude), + longitude: Number(longitude), + time: time instanceof Date ? time : new Date(), + speed: speed !== undefined ? Number(speed) : undefined, + orientation: orientation !== undefined ? Number(orientation) : undefined, + mileage: mileage !== undefined ? Number(mileage) : undefined, + extra, + }; +} + +function legacyParts(packet: ParsedPacket): LegacyMsgParts { + return { + device_id: packet.deviceId, + cmd: packet.cmd, + data: packet.data, + action: packet.action, + }; +} + +/** + * @deprecated v1 device wrapper. Use the v2 {@link Device} (`server.on('connection', device => ...)`). + */ +export class LegacyDevice extends EventEmitter { + /** The v2 device, if you want to migrate gradually. */ + readonly v2: Device; + uid: string | undefined; + name: string | false = false; + readonly ip: string | undefined; + readonly port: number | undefined; + + constructor(device: Device) { + super(); + this.v2 = device; + this.uid = device.id; + this.ip = device.remoteAddress; + this.port = device.remotePort; + + device.on('identified', (id) => { + this.uid = id; + }); + device.on('loginRequest', (deviceId, packet) => { + this.emit('login_request', deviceId, legacyParts(packet)); + }); + device.on('login', () => this.emit('login')); + device.on('ping', (position, packet) => { + const { extra, ...core } = position; + this.emit('ping', { ...core, ...extra, from_cmd: packet.cmd }, legacyParts(packet)); + }); + device.on('alarm', (alarm: Alarm, packet) => { + this.emit('alarm', alarm.code, { ...alarm, msg: alarm.message }, legacyParts(packet)); + }); + } + + get loged(): boolean { + return this.v2.isAuthenticated; + } + + /** @deprecated Use `device.acceptLogin()` / `device.rejectLogin()`. */ + login_authorized(val: boolean): void { + if (val) { + this.v2.acceptLogin(); + } else { + this.v2.rejectLogin(); + } + } + + /** @deprecated Use `device.disconnect()`. */ + logout(): void { + this.v2.rejectLogin(); + } + + /** @deprecated Use `device.send()`. */ + send(msg: Buffer | string): void { + this.emit('send_data', msg); + this.v2.send(msg); + } + + /** @deprecated Use `device.setRefreshInterval()`. */ + set_refresh_time(interval: number, duration: number): void { + this.v2.setRefreshInterval(interval, duration); + } + + /** @deprecated Use `device.id`. */ + getUID(): string | false { + return this.uid ?? false; + } + + /** @deprecated The v2 device id is read-only. */ + setUID(uid: string): void { + this.uid = uid; + } + + /** @deprecated Use `device.name`. */ + getName(): string | false { + return this.name; + } + + /** @deprecated Use `device.name`. */ + setName(name: string): void { + this.name = name; + this.v2.name = name; + } + + /** @deprecated Pass a `logger` to createServer instead. */ + do_log(msg: string): void { + console.log(`#${this.uid ?? '?'}: ${msg}`); + } +} + +/** + * @deprecated v1 server wrapper returned by {@link server}. Use {@link createServer}. + */ +export class LegacyServer extends EventEmitter { + /** The v2 server, if you want to migrate gradually. */ + readonly v2: GpsServer; + /** @deprecated v1-style list of connections (`net.Socket`s with a `.device` property). */ + readonly devices: Socket[] = []; + #debug: boolean; + + constructor(gpsServer: GpsServer, options: LegacyServerOptions, callback?: (device: LegacyDevice, connection: Socket) => void) { + super(); + this.v2 = gpsServer; + this.#debug = options.debug === true; + + gpsServer.on('connection', (device) => { + const legacyDevice = new LegacyDevice(device); + const socket = device.socket as LegacySocket; + socket.device = legacyDevice; + this.devices.push(socket); + callback?.(legacyDevice, socket); + legacyDevice.emit('connected'); + }); + gpsServer.on('disconnect', (device) => { + const socket = device.socket as LegacySocket; + const index = this.devices.indexOf(socket); + if (index !== -1) { + this.devices.splice(index, 1); + } + socket.device?.emit('disconnected'); + }); + } + + /** @deprecated Use `server.getDevice(id)`. */ + find_device(deviceId: string): LegacyDevice | false { + const device = this.v2.getDevice(deviceId); + if (!device) { + return false; + } + return (device.socket as LegacySocket).device ?? false; + } + + /** @deprecated Use `server.sendTo(id, data)`. */ + send_to(deviceId: string, msg: Buffer | string): void { + this.v2.sendTo(deviceId, msg); + } + + /** @deprecated Pass a `logger` to createServer instead. */ + setDebug(val: boolean): void { + this.#debug = val === true; + } + + /** @deprecated */ + getDebug(): boolean { + return this.#debug; + } + + /** @deprecated */ + do_log(msg: string, from = 'SERVER'): void { + if (this.#debug) { + console.log(`#${from}: ${msg}`); + } + } +} + +function resolveAdapter(deviceAdapter: LegacyServerOptions['device_adapter']): AdapterClass { + if (!deviceAdapter) { + throw new AdapterError( + "The app don't set the device_adapter to use. Which model is sending data to this server?", + ); + } + if (typeof deviceAdapter === 'string') { + const adapterClass = (adapters as Record)[deviceAdapter.toUpperCase()]; + if (!adapterClass) { + throw new AdapterError(`The class adapter for ${deviceAdapter} doesn't exist`); + } + return adapterClass; + } + if (typeof deviceAdapter === 'function') { + return deviceAdapter; + } + return wrapLegacyAdapter(deviceAdapter); +} + +/** + * @deprecated v1 entry point, kept for backwards compatibility. + * Use {@link createServer} instead: + * ```ts + * const server = createServer({ port: 8090, adapter: adapters.TK103 }); + * server.on('connection', device => { ... }); + * await server.listen(); + * ``` + */ +export function server( + options: LegacyServerOptions, + callback?: (device: LegacyDevice, connection: Socket) => void, +): LegacyServer { + warnDeprecated(); + const adapterClass = resolveAdapter(options.device_adapter); + const gpsServer = createServer({ + adapter: adapterClass, + port: options.port ?? 8080, + logger: options.debug === true ? console : false, + }); + const legacy = new LegacyServer(gpsServer, options, callback); + gpsServer + .listen() + .then(({ port }) => { + console.log( + `\n=================================================\nGPS LISTENER running at port ${port}\nEXPECTING DEVICE MODEL: ${adapterClass.modelName}\n=================================================\n`, + ); + }) + .catch((error: Error) => { + if (legacy.listenerCount('error') > 0) { + legacy.emit('error', error); + } else { + throw error; + } + }); + return legacy; +} diff --git a/src/device.ts b/src/device.ts new file mode 100644 index 0000000..92c3822 --- /dev/null +++ b/src/device.ts @@ -0,0 +1,200 @@ +import type { Socket } from 'node:net'; +import { TypedEmitter } from './typed-emitter.js'; +import { PacketParseError } from './errors.js'; +import { DEFAULT_MAX_FRAME_LENGTH, type Framer } from './framing/framer.js'; +import type { BaseAdapter, AdapterClass } from './adapters/base-adapter.js'; +import type { DeviceEvents, Logger, ParsedPacket } from './types.js'; + +export interface DeviceOptions { + logger?: Logger | false; + maxFrameLength?: number; + /** Destroy the connection after this much idle time. 0 disables. */ + connectionTimeoutMs?: number; +} + +/** + * One connected GPS tracker. Created by GpsServer for every TCP connection. + * The Device owns its socket lifecycle: it subscribes to data/error/timeout/close + * and emits its own events; GpsServer only does registry bookkeeping on top. + */ +export class Device extends TypedEmitter { + /** The raw TCP socket, for advanced use. */ + readonly socket: Socket; + readonly adapter: BaseAdapter; + /** Free-form label you can assign to this device. */ + name: string | undefined; + + readonly #framer: Framer; + readonly #logger: Logger | false; + readonly #requiresLogin: boolean; + #id: string | undefined; + #authenticated = false; + #pendingLogin: ParsedPacket | undefined; + + constructor(socket: Socket, adapterClass: AdapterClass, options: DeviceOptions = {}) { + super(); + this.socket = socket; + this.#logger = options.logger ?? false; + this.#requiresLogin = adapterClass.requiresLogin ?? true; + this.adapter = new adapterClass(this); + this.#framer = this.adapter.createFramer({ + maxFrameLength: options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH, + }); + + if (options.connectionTimeoutMs && options.connectionTimeoutMs > 0) { + socket.setTimeout(options.connectionTimeoutMs); + } + socket.on('data', (chunk: Buffer) => this.handleData(chunk)); + socket.on('error', (error: Error) => { + // A dropped connection (ECONNRESET & friends) must never crash the process. + // Emitting 'error' with no listeners would throw, so only emit when someone listens. + this.#log('warn', () => `socket error: ${error.message}`); + if (this.listenerCount('error') > 0) { + this.emit('error', error); + } + socket.destroy(); + }); + socket.on('timeout', () => { + this.#log('debug', () => 'connection timed out'); + this.emit('timeout'); + socket.destroy(); + }); + socket.on('close', () => { + this.emit('disconnect'); + }); + } + + /** Device id (IMEI or protocol id). Undefined until the first packet that carries it. */ + get id(): string | undefined { + return this.#id; + } + + get remoteAddress(): string | undefined { + return this.socket.remoteAddress; + } + + get remotePort(): number | undefined { + return this.socket.remotePort; + } + + get isAuthenticated(): boolean { + return this.#authenticated; + } + + /** Feed raw data as if it came from the socket (useful for custom transports/tests). */ + handleData(chunk: Buffer): void { + for (const frame of this.#framer.push(chunk)) { + this.#handleFrame(frame); + } + } + + #handleFrame(frame: Buffer): void { + let packet: ParsedPacket; + try { + packet = this.adapter.parsePacket(frame); + } catch (error) { + const parseError = + error instanceof PacketParseError + ? error + : new PacketParseError(error instanceof Error ? error.message : String(error), { + cause: error, + }); + this.#log('warn', () => `discarding unparseable frame: ${parseError.message}`); + this.emit('parseError', parseError, frame); + return; + } + + if (packet.action === 'ignore') { + return; + } + + if (packet.deviceId && !this.#id) { + this.#id = packet.deviceId; + this.emit('identified', packet.deviceId); + } + + if (packet.action === 'loginRequest') { + this.#pendingLogin = packet; + this.#log('debug', () => 'login requested'); + this.emit('loginRequest', packet.deviceId, packet); + return; + } + + if (!this.#authenticated) { + if (this.#requiresLogin) { + this.adapter.requestLogin(); + this.#log('debug', () => `'${packet.action}' received before login; discarded`); + return; + } + this.#authenticated = true; + this.emit('login'); + } + + switch (packet.action) { + case 'ping': + this.#log('debug', () => `position (${packet.position.latitude}, ${packet.position.longitude})`); + this.adapter.ackPing(packet); + this.emit('ping', packet.position, packet); + break; + case 'alarm': + this.adapter.ackAlarm(packet); + this.emit('alarm', packet.alarm, packet); + break; + case 'heartbeat': + this.adapter.ackHeartbeat(packet); + this.emit('packet', packet); + break; + case 'other': + this.adapter.handleCommand(packet); + this.emit('packet', packet); + break; + } + } + + /** Accept a pending login request: authenticates the device and sends the protocol ack. */ + acceptLogin(): void { + const packet = this.#pendingLogin; + if (!packet) { + this.#log('warn', () => 'acceptLogin() called without a pending login request'); + return; + } + this.#authenticated = true; + this.#pendingLogin = undefined; + this.adapter.authorize(packet); + this.#log('debug', () => 'login accepted'); + this.emit('login'); + } + + /** Reject a pending login request; optionally drop the connection. */ + rejectLogin(options: { disconnect?: boolean } = {}): void { + this.#pendingLogin = undefined; + this.#authenticated = false; + this.#log('debug', () => 'login rejected'); + if (options.disconnect) { + this.disconnect(); + } + } + + /** Write raw data to the device. */ + send(data: Buffer | string): boolean { + this.#log('debug', () => `sending: ${typeof data === 'string' ? data : data.toString('hex')}`); + return this.socket.write(data); + } + + /** Configure the device reporting interval, when the protocol supports it. */ + setRefreshInterval(intervalSeconds: number, durationSeconds: number): void { + this.adapter.setRefreshInterval(intervalSeconds, durationSeconds); + } + + /** Destroy the TCP connection. */ + disconnect(): void { + this.socket.destroy(); + } + + // The message is a thunk so disabled logging (the default) costs nothing per packet. + #log(level: 'debug' | 'warn', message: () => string): void { + if (this.#logger) { + this.#logger[level](`[gps-tracking] #${this.#id ?? this.remoteAddress ?? '?'}: ${message()}`); + } + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..5773752 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,15 @@ +export class GpsTrackingError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = new.target.name; + } +} + +/** A frame could not be parsed by the adapter. Emitted as a `parseError` event, never thrown to the process. */ +export class PacketParseError extends GpsTrackingError {} + +/** An adapter was misconfigured or returned invalid data. */ +export class AdapterError extends GpsTrackingError {} + +/** The protocol/adapter does not support the requested operation. */ +export class NotSupportedError extends GpsTrackingError {} diff --git a/src/framing/delimiter-framer.ts b/src/framing/delimiter-framer.ts new file mode 100644 index 0000000..e158438 --- /dev/null +++ b/src/framing/delimiter-framer.ts @@ -0,0 +1,60 @@ +import { DEFAULT_MAX_FRAME_LENGTH, EMPTY_BUFFER, type Framer } from './framer.js'; + +export interface DelimiterFramerOptions { + /** Frame start byte, e.g. 0x28 `(` or 0x2a `*`. */ + start: number; + /** Frame end byte, e.g. 0x29 `)` or 0x23 `#`. */ + end: number; + /** Frames longer than this are discarded and the stream re-synced. Default 4096. */ + maxFrameLength?: number; +} + +/** + * Framer for text protocols delimited by single start/end bytes, + * such as TK103 `(...)` and H02 `*...#`. Emitted frames include both + * delimiter bytes. Bytes outside frames are discarded. + */ +export class DelimiterFramer implements Framer { + readonly #start: number; + readonly #end: number; + readonly #maxFrameLength: number; + #buffer: Buffer = EMPTY_BUFFER; + + constructor(options: DelimiterFramerOptions) { + this.#start = options.start; + this.#end = options.end; + this.#maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH; + } + + push(chunk: Buffer): Buffer[] { + this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]); + const frames: Buffer[] = []; + + for (;;) { + const start = this.#buffer.indexOf(this.#start); + if (start === -1) { + this.#buffer = EMPTY_BUFFER; + break; + } + if (start > 0) { + this.#buffer = this.#buffer.subarray(start); + } + const end = this.#buffer.indexOf(this.#end, 1); + if (end === -1) { + if (this.#buffer.length > this.#maxFrameLength) { + this.#buffer = EMPTY_BUFFER; + } + break; + } + if (end + 1 > this.#maxFrameLength) { + // Oversized but terminated frame: discard it and keep going after it. + this.#buffer = this.#buffer.subarray(end + 1); + continue; + } + frames.push(this.#buffer.subarray(0, end + 1)); + this.#buffer = this.#buffer.subarray(end + 1); + } + + return frames; + } +} diff --git a/src/framing/framer.ts b/src/framing/framer.ts new file mode 100644 index 0000000..46bbbc8 --- /dev/null +++ b/src/framing/framer.ts @@ -0,0 +1,31 @@ +/** Default cap for a single frame; larger data is treated as corrupt. */ +export const DEFAULT_MAX_FRAME_LENGTH = 4096; + +/** Shared zero-length buffer used by framers when their buffer drains. */ +export const EMPTY_BUFFER: Buffer = Buffer.alloc(0); + +/** Options every framer receives from the infrastructure. */ +export interface FramerOptions { + maxFrameLength: number; +} + +/** + * A Framer turns a TCP byte stream into complete protocol frames. + * TCP gives no message boundaries: a single `data` event can hold half a + * packet or three packets — the framer buffers and re-slices accordingly. + */ +export interface Framer { + /** Feed a chunk from the socket; returns every complete frame now available. */ + push(chunk: Buffer): Buffer[]; +} + +/** + * No framing: every chunk is assumed to be exactly one frame. + * This replicates the (buggy) v1 behaviour and exists only so that + * legacy v1 adapters keep working unchanged via the compat layer. + */ +export class PassthroughFramer implements Framer { + push(chunk: Buffer): Buffer[] { + return [chunk]; + } +} diff --git a/src/framing/length-prefixed-framer.ts b/src/framing/length-prefixed-framer.ts new file mode 100644 index 0000000..9a3cca9 --- /dev/null +++ b/src/framing/length-prefixed-framer.ts @@ -0,0 +1,87 @@ +import { DEFAULT_MAX_FRAME_LENGTH, EMPTY_BUFFER, type Framer } from './framer.js'; + +export interface FrameMarker { + /** Start-of-frame marker bytes, e.g. Buffer 0x78 0x78. */ + marker: Buffer; + /** Byte offset of the length field, from the start of the frame. */ + lengthOffset: number; + /** Size of the length field (big-endian when 2). */ + lengthBytes: 1 | 2; + /** Total frame length in bytes as a function of the length-field value. */ + totalLength: (length: number) => number; +} + +export interface LengthPrefixedFramerOptions { + markers: readonly FrameMarker[]; + /** Frames longer than this are treated as corrupt; the stream re-syncs. Default 4096. */ + maxFrameLength?: number; +} + +/** + * Framer for binary protocols with a start marker and a length field, + * such as GT06/GK309 (0x7878 / 0x7979), GT02A (0x6868) and TK510 (0x4040). + * Bytes before a marker are discarded; corrupt lengths skip the marker to re-sync. + */ +export class LengthPrefixedFramer implements Framer { + readonly #markers: readonly FrameMarker[]; + readonly #maxFrameLength: number; + readonly #maxMarkerLength: number; + #buffer: Buffer = EMPTY_BUFFER; + + constructor(options: LengthPrefixedFramerOptions) { + this.#markers = options.markers; + this.#maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH; + this.#maxMarkerLength = Math.max(...options.markers.map((m) => m.marker.length)); + } + + push(chunk: Buffer): Buffer[] { + this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]); + const frames: Buffer[] = []; + + for (;;) { + const found = this.#findMarker(); + if (!found) { + // Keep a marker-length tail in case a marker straddles two chunks. + if (this.#buffer.length > this.#maxMarkerLength - 1) { + this.#buffer = this.#buffer.subarray(this.#buffer.length - (this.#maxMarkerLength - 1)); + } + break; + } + const { marker, index } = found; + if (index > 0) { + this.#buffer = this.#buffer.subarray(index); + } + if (this.#buffer.length < marker.lengthOffset + marker.lengthBytes) { + break; + } + const length = + marker.lengthBytes === 1 + ? this.#buffer.readUInt8(marker.lengthOffset) + : this.#buffer.readUInt16BE(marker.lengthOffset); + const total = marker.totalLength(length); + if (total <= marker.marker.length || total > this.#maxFrameLength) { + // Corrupt length: skip one byte past the marker start to re-sync. + this.#buffer = this.#buffer.subarray(1); + continue; + } + if (this.#buffer.length < total) { + break; + } + frames.push(this.#buffer.subarray(0, total)); + this.#buffer = this.#buffer.subarray(total); + } + + return frames; + } + + #findMarker(): { marker: FrameMarker; index: number } | undefined { + let best: { marker: FrameMarker; index: number } | undefined; + for (const marker of this.#markers) { + const index = this.#buffer.indexOf(marker.marker); + if (index !== -1 && (best === undefined || index < best.index)) { + best = { marker, index }; + } + } + return best; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..887b34d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,44 @@ +export { createServer, GpsServer } from './server.js'; +export { Device, type DeviceOptions } from './device.js'; +export { + adapters, + BaseAdapter, + Tk103Adapter, + Tk510Adapter, + Gt06Adapter, + Gk309Adapter, + Gt02aAdapter, + St901Adapter, + type AdapterClass, +} from './adapters/index.js'; +export { type Framer, type FramerOptions, PassthroughFramer, DEFAULT_MAX_FRAME_LENGTH } from './framing/framer.js'; +export { DelimiterFramer, type DelimiterFramerOptions } from './framing/delimiter-framer.js'; +export { + LengthPrefixedFramer, + type LengthPrefixedFramerOptions, + type FrameMarker, +} from './framing/length-prefixed-framer.js'; +export { GpsTrackingError, PacketParseError, AdapterError, NotSupportedError } from './errors.js'; +export { crc16X25 } from './lib/crc.js'; +export { minuteToDecimal, minutes30000ToDegrees, getDistance, KNOTS_TO_KMH, type LatLng } from './lib/geo.js'; +export type { + GpsPosition, + Alarm, + ParsedPacket, + PacketBase, + Logger, + ServerOptions, + ServerEvents, + DeviceEvents, +} from './types.js'; + +// v1 compatibility layer (deprecated). +export { + server, + LegacyServer, + LegacyDevice, + type LegacyServerOptions, + type LegacyAdapterModule, +} from './compat.js'; + +export const version = '2.0.0-beta.1'; diff --git a/src/lib/crc.ts b/src/lib/crc.ts new file mode 100644 index 0000000..2a2f258 --- /dev/null +++ b/src/lib/crc.ts @@ -0,0 +1,23 @@ +// 256-entry lookup table, built once at module load (~0.5 KB). +const TABLE = new Uint16Array(256); +for (let i = 0; i < 256; i++) { + let crc = i; + for (let bit = 0; bit < 8; bit++) { + crc = crc & 1 ? (crc >>> 1) ^ 0x8408 : crc >>> 1; + } + TABLE[i] = crc; +} + +/** + * CRC-16/X-25 (also known as CRC-ITU): reflected polynomial 0x8408, + * init 0xFFFF, final XOR 0xFFFF. Used by the Concox GT06/GK309 family + * and by TK510 frames. Identical output to the old `crc16-ccitt-node` + * and `crc-itu` dependencies this package used in v1. + */ +export function crc16X25(data: Uint8Array): number { + let crc = 0xffff; + for (const byte of data) { + crc = TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + } + return ~crc & 0xffff; +} diff --git a/src/lib/geo.ts b/src/lib/geo.ts new file mode 100644 index 0000000..56e6d6e --- /dev/null +++ b/src/lib/geo.ts @@ -0,0 +1,49 @@ +/** Knots → km/h conversion factor (GPS protocols usually report speed in knots). */ +export const KNOTS_TO_KMH = 1.852; + +/** + * Convert a Concox-family coordinate (unsigned integer in 1/30000 of a minute, + * i.e. 1/500 of a second) to decimal degrees. Used by GT06/GK309/GT02A. + */ +export function minutes30000ToDegrees(value: number): number { + return value / 30000 / 60; +} + +/** + * Convert a NMEA-style degrees+minutes value (DDMM.MMMM / DDDMM.MMMM) + * to decimal degrees. Negative for southern/western hemispheres. + * Returns NaN for malformed input (minutes >= 60, out-of-range degrees, + * unknown hemisphere) so parsers reject it instead of emitting a bogus fix. + */ +export function minuteToDecimal(value: number, hemisphere: string = 'N'): number { + const degrees = Math.floor(value / 100); + const minutes = value - degrees * 100; + const h = hemisphere.toUpperCase(); + const maxDegrees = h === 'E' || h === 'W' ? 180 : h === 'N' || h === 'S' ? 90 : Number.NaN; + if (!Number.isFinite(value) || degrees < 0 || !(degrees <= maxDegrees) || minutes >= 60) { + return Number.NaN; + } + const decimal = degrees + minutes / 60; + return h === 'S' || h === 'W' ? -decimal : decimal; +} + +export interface LatLng { + lat: number; + lng: number; +} + +const EARTH_RADIUS_M = 6378137; + +function rad(deg: number): number { + return (deg * Math.PI) / 180; +} + +/** Haversine distance between two points, in meters. */ +export function getDistance(p1: LatLng, p2: LatLng): number { + const dLat = rad(p2.lat - p1.lat); + const dLng = rad(p2.lng - p1.lng); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(rad(p1.lat)) * Math.cos(rad(p2.lat)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); + return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts new file mode 100644 index 0000000..6bfe00f --- /dev/null +++ b/src/lib/protocol.ts @@ -0,0 +1,120 @@ +/** + * Shared protocol-parsing utilities used by the built-in adapters. + */ +import { PacketParseError } from '../errors.js'; +import { KNOTS_TO_KMH, minuteToDecimal } from './geo.js'; +import type { Alarm, GpsPosition } from '../types.js'; + +/** + * Build a UTC Date from date/time components (2-digit year). + * Throws PacketParseError on non-integer or overflowing components + * (Date.UTC would silently normalize e.g. month 13 into the next year). + */ +export function utcDate( + yy: number, + month: number, + day: number, + hours: number, + minutes: number, + seconds: number, +): Date { + const values = [yy, month, day, hours, minutes, seconds]; + if (!values.every(Number.isInteger) || yy < 0 || yy > 99) { + throw new PacketParseError('invalid date components'); + } + const date = new Date(Date.UTC(2000 + yy, month - 1, day, hours, minutes, seconds)); + if ( + date.getUTCFullYear() !== 2000 + yy || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day || + date.getUTCHours() !== hours || + date.getUTCMinutes() !== minutes || + date.getUTCSeconds() !== seconds + ) { + throw new PacketParseError('invalid date components'); + } + return date; +} + +/** Parse a pair of ASCII digits from a string at the given offset. NaN if not two digits. */ +export function digits2(str: string, offset: number): number { + const pair = str.slice(offset, offset + 2); + return /^\d{2}$/.test(pair) ? Number(pair) : Number.NaN; +} + +/** RMC-style DDMMYY date + HHMMSS time → UTC Date. */ +export function rmcDate(ddmmyy: string, hhmmss: string): Date { + return utcDate( + digits2(ddmmyy, 4), + digits2(ddmmyy, 2), + digits2(ddmmyy, 0), + digits2(hhmmss, 0), + digits2(hhmmss, 2), + digits2(hhmmss, 4), + ); +} + +/** Six binary bytes YY MM DD HH mm ss (UTC), as used by the Concox family. */ +export function binaryDate(data: Buffer, offset = 0): Date { + return utcDate( + data.readUInt8(offset), + data.readUInt8(offset + 1), + data.readUInt8(offset + 2), + data.readUInt8(offset + 3), + data.readUInt8(offset + 4), + data.readUInt8(offset + 5), + ); +} + +/** 8 BCD bytes = 16 digits = a 15-digit IMEI with a leading zero. */ +export function bcdImei(bytes: Buffer): string { + return bytes.toString('hex').replace(/^0/, ''); +} + +/** + * Parse RMC-like comma-separated position fields starting at `offset`: + * time(HHMMSS), validity(A/V), lat(DDMM.MMMM), N/S, lon(DDDMM.MMMM), E/W, + * speed(knots), course, date(DDMMYY). Returns the protocol-agnostic core; + * adapters layer their protocol-specific `extra` on top. + */ +export function parseRmcPosition(fields: string[], offset: number, context: string): GpsPosition { + if (fields.length < offset + 9) { + throw new PacketParseError(`${context}: position payload too short: ${fields.join(',')}`); + } + const field = (index: number) => (fields[offset + index] ?? '').trim(); + const latitude = minuteToDecimal(Number.parseFloat(field(2)), field(3) || 'N'); + const longitude = minuteToDecimal(Number.parseFloat(field(4)), field(5) || 'E'); + if (Number.isNaN(latitude) || Number.isNaN(longitude)) { + throw new PacketParseError(`${context}: invalid coordinates in payload: ${fields.join(',')}`); + } + const course = field(7); + return { + latitude, + longitude, + time: rmcDate(field(8), field(0)), + valid: field(1) === 'A', + speed: Number.parseFloat(field(6) || '0') * KNOTS_TO_KMH, + orientation: course === '' ? 0 : Number.parseFloat(course), + }; +} + +/** Look up a protocol alarm code, synthesizing a consistent unknown-alarm shape. */ +export function lookupAlarm(table: Record, raw: string, protocol: string): Alarm { + return table[raw] ?? { code: `alarm_${raw}`, message: `Unknown ${protocol} alarm ${raw}`, raw }; +} + +/** + * Attach `data` to a packet as a lazy, cached hex encoding of `content`. + * Binary payloads are only hex-encoded if someone actually reads `.data`. + */ +export function lazyHexData(packet: T, content: Buffer): T { + return Object.defineProperty(packet, 'data', { + enumerable: true, + configurable: true, + get(): string { + const value = content.toString('hex'); + Object.defineProperty(packet, 'data', { value, enumerable: true, configurable: true, writable: true }); + return value; + }, + }); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..a7d961c --- /dev/null +++ b/src/server.ts @@ -0,0 +1,140 @@ +import net, { type AddressInfo, type Socket } from 'node:net'; +import { TypedEmitter } from './typed-emitter.js'; +import { Device } from './device.js'; +import { AdapterError } from './errors.js'; +import { DEFAULT_MAX_FRAME_LENGTH } from './framing/framer.js'; +import type { AdapterClass } from './adapters/base-adapter.js'; +import type { Logger, ServerEvents, ServerOptions } from './types.js'; + +const DEFAULT_PORT = 8090; +const DEFAULT_CONNECTION_TIMEOUT_MS = 120_000; + +/** + * TCP server that listens for one GPS tracker protocol. + * Create it with {@link createServer}, subscribe to `connection`, then `listen()`. + */ +export class GpsServer extends TypedEmitter { + readonly #net: net.Server; + readonly #adapter: AdapterClass; + readonly #port: number; + readonly #host: string | undefined; + readonly #connectionTimeoutMs: number; + readonly #maxFrameLength: number; + readonly #logger: Logger | false; + readonly #devices = new Map(); + readonly #connections = new Set(); + + constructor(options: ServerOptions) { + super(); + if (typeof options.adapter !== 'function') { + throw new AdapterError( + 'ServerOptions.adapter must be an adapter class, e.g. adapters.TK103 (see MIGRATION.md if you are coming from v1)', + ); + } + this.#adapter = options.adapter; + this.#port = options.port ?? DEFAULT_PORT; + this.#host = options.host; + this.#connectionTimeoutMs = options.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS; + this.#maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH; + this.#logger = options.logger ?? false; + this.#net = net.createServer((socket) => this.#handleConnection(socket)); + this.#net.on('error', (error) => { + this.#log('warn', `server error: ${error.message}`); + if (this.listenerCount('error') > 0) { + this.emit('error', error); + } + }); + } + + /** Devices that have identified themselves, keyed by device id. */ + get devices(): ReadonlyMap { + return this.#devices; + } + + /** The adapter class this server was created with. */ + get adapter(): AdapterClass { + return this.#adapter; + } + + getDevice(deviceId: string): Device | undefined { + return this.#devices.get(deviceId); + } + + /** Send raw data to a connected device by id. Returns false if it is not connected. */ + sendTo(deviceId: string, data: Buffer | string): boolean { + const device = this.#devices.get(deviceId); + if (!device) { + return false; + } + return device.send(data); + } + + /** Start listening. Resolves with the bound address (use port 0 for a random port). */ + listen(): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + this.#net.once('error', onError); + this.#net.listen(this.#port, this.#host, () => { + this.#net.off('error', onError); + const address = this.#net.address() as AddressInfo; + this.#log('info', `listening on port ${address.port} for ${this.#adapter.modelName || this.#adapter.name} devices`); + this.emit('listening', address); + resolve(address); + }); + }); + } + + /** Stop listening and destroy every open connection. */ + close(): Promise { + return new Promise((resolve, reject) => { + for (const device of this.#connections) { + device.disconnect(); + } + this.#net.close((error) => { + if (error) { + reject(error); + return; + } + this.emit('close'); + resolve(); + }); + }); + } + + #handleConnection(socket: Socket): void { + const device = new Device(socket, this.#adapter, { + logger: this.#logger, + maxFrameLength: this.#maxFrameLength, + connectionTimeoutMs: this.#connectionTimeoutMs, + }); + this.#connections.add(device); + this.#log('debug', `connection from ${socket.remoteAddress ?? '?'}`); + + // Register only after authentication: an unauthenticated peer claiming an + // existing id must not become routable via getDevice()/sendTo(). + device.on('login', () => { + if (device.id) { + this.#devices.set(device.id, device); + } + }); + device.on('disconnect', () => { + this.#connections.delete(device); + if (device.id && this.#devices.get(device.id) === device) { + this.#devices.delete(device.id); + } + this.emit('disconnect', device); + }); + + this.emit('connection', device); + } + + #log(level: 'debug' | 'info' | 'warn', message: string): void { + if (this.#logger) { + this.#logger[level](`[gps-tracking] ${message}`); + } + } +} + +export function createServer(options: ServerOptions): GpsServer { + return new GpsServer(options); +} diff --git a/src/typed-emitter.ts b/src/typed-emitter.ts new file mode 100644 index 0000000..f91a12e --- /dev/null +++ b/src/typed-emitter.ts @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging, @typescript-eslint/no-unused-vars -- + * Class/interface declaration merging is the standard way to narrow EventEmitter's + * signatures without a runtime wrapper (same pattern as tiny-typed-emitter). */ +import { EventEmitter } from 'node:events'; + +export type EventMap = Record; + +/** + * EventEmitter with typed event names and listener signatures. + * Runtime behaviour is exactly Node's EventEmitter; only the types are narrowed. + */ +export class TypedEmitter extends EventEmitter {} + +export interface TypedEmitter { + on(event: K, listener: (...args: T[K]) => void): this; + once(event: K, listener: (...args: T[K]) => void): this; + off(event: K, listener: (...args: T[K]) => void): this; + addListener(event: K, listener: (...args: T[K]) => void): this; + removeListener(event: K, listener: (...args: T[K]) => void): this; + emit(event: K, ...args: T[K]): boolean; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..46eab7b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,96 @@ +import type { AddressInfo } from 'node:net'; +import type { Device } from './device.js'; +import type { PacketParseError } from './errors.js'; +import type { AdapterClass } from './adapters/base-adapter.js'; + +export interface GpsPosition { + latitude: number; + longitude: number; + /** UTC fix time as reported by the device. */ + time: Date; + /** GPS fix validity when the protocol reports it. */ + valid?: boolean; + /** Speed in km/h. */ + speed?: number; + /** Course over ground in degrees (0-360, north = 0). */ + orientation?: number; + /** Odometer/mileage when the protocol reports it. */ + mileage?: number; + satellites?: number; + /** Protocol-specific fields (LBS info, IO state, ignition, battery...). */ + extra?: Record; +} + +export interface Alarm { + /** Normalized code: 'sos', 'power_off', 'low_battery', 'overspeed', 'geofence_enter'... */ + code: string; + message: string; + /** Raw protocol alarm code, useful for acks and debugging. */ + raw?: string; +} + +export interface PacketBase { + /** Protocol command, e.g. 'BR00' (TK103) or '16' (GT06 protocol number in hex). */ + cmd: string; + deviceId?: string; + /** The complete frame as received. */ + raw: Buffer; + /** Command payload (text protocols: string after the command; binary: hex string). */ + data?: string; + /** Frame serial number, for protocols that ack by serial (GT06 family). */ + serial?: number; +} + +export type ParsedPacket = + | (PacketBase & { action: 'loginRequest'; deviceId: string }) + | (PacketBase & { action: 'ping'; position: GpsPosition }) + | (PacketBase & { action: 'alarm'; alarm: Alarm; position?: GpsPosition }) + | (PacketBase & { action: 'heartbeat' }) + | (PacketBase & { action: 'other' }) + | (PacketBase & { action: 'ignore' }); + +export interface Logger { + debug(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; +} + +export interface ServerOptions { + /** Adapter class for the protocol this server listens for, e.g. `adapters.TK103`. */ + adapter: AdapterClass; + /** TCP port to listen on. Default 8090. Use 0 for a random free port. */ + port?: number; + host?: string; + /** Idle sockets are destroyed after this. Default 120000 ms. Set 0 to disable. */ + connectionTimeoutMs?: number; + /** Maximum accepted frame size in bytes. Default 4096. */ + maxFrameLength?: number; + /** Pass `console` (or any Logger) to enable logging. Default: silent. */ + logger?: Logger | false; +} + +export type ServerEvents = { + listening: [address: AddressInfo]; + connection: [device: Device]; + disconnect: [device: Device]; + error: [error: Error]; + close: []; +}; + +export type DeviceEvents = { + /** The device asked to log in. Call `device.acceptLogin()` or `device.rejectLogin()`. */ + loginRequest: [deviceId: string, packet: ParsedPacket]; + /** The device is authenticated (after acceptLogin, or automatically for login-less protocols). */ + login: []; + /** The device id became known (first packet that carries it). */ + identified: [deviceId: string]; + ping: [position: GpsPosition, packet: ParsedPacket]; + alarm: [alarm: Alarm, packet: ParsedPacket]; + /** Any other packet (heartbeats, protocol-specific commands). */ + packet: [packet: ParsedPacket]; + parseError: [error: PacketParseError, frame: Buffer]; + error: [error: Error]; + timeout: []; + disconnect: []; +}; diff --git a/test/helpers/fake-device.ts b/test/helpers/fake-device.ts new file mode 100644 index 0000000..16978f6 --- /dev/null +++ b/test/helpers/fake-device.ts @@ -0,0 +1,21 @@ +import type { Device } from '../../src/device.js'; + +/** + * Minimal Device stand-in for unit-testing adapters: + * captures everything the adapter sends and exposes a settable id. + */ +export function fakeDevice(id?: string) { + const sent: (Buffer | string)[] = []; + const device = { + id, + send(data: Buffer | string): boolean { + sent.push(data); + return true; + }, + } as unknown as Device; + return { device, sent }; +} + +export function sentAsStrings(sent: (Buffer | string)[]): string[] { + return sent.map((item) => (typeof item === 'string' ? item : item.toString('hex'))); +} diff --git a/test/helpers/tcp.ts b/test/helpers/tcp.ts new file mode 100644 index 0000000..6233938 --- /dev/null +++ b/test/helpers/tcp.ts @@ -0,0 +1,18 @@ +import net from 'node:net'; + +// TK103 packets from the original protocol docs / v1 readme, shared by the +// integration suites (unit tests keep their own protocol-specific fixtures). +export const TK103_LOGIN = + '(012341234123BP05000012341234123140607A3330.4288S07036.8518W019.2230104172.3900000000L00019C2C)'; +export const TK103_PING = + '(012341234123BR00140607A3330.4288S07036.8518W019.2230104172.3900000000L00019C2C)'; +export const TK103_DEVICE_ID = '012341234123'; + +/** Connect to 127.0.0.1:port; `track` receives the socket for cleanup bookkeeping. */ +export function connect(port: number, track: (socket: net.Socket) => void): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(port, '127.0.0.1', () => resolve(socket)); + socket.on('error', reject); + track(socket); + }); +} diff --git a/test/integration/compat.test.ts b/test/integration/compat.test.ts new file mode 100644 index 0000000..f3bf7c3 --- /dev/null +++ b/test/integration/compat.test.ts @@ -0,0 +1,143 @@ +import type net from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as gps from '../../src/index.js'; +import type { LegacyServer, LegacyDevice } from '../../src/compat.js'; +import { connect as tcpConnect, TK103_LOGIN as LOGIN, TK103_PING as PING } from '../helpers/tcp.js'; + +const cleanups: (() => Promise | void)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup(); + } + vi.restoreAllMocks(); +}); + +function connect(port: number): Promise { + return tcpConnect(port, (socket) => { + cleanups.push(() => { + socket.destroy(); + }); + }); +} + +/** Boot a v1-style server (the README v1 example, verbatim API) on a random port. */ +async function startLegacyServer(): Promise<{ server: LegacyServer; port: number; pings: unknown[] }> { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const pings: unknown[] = []; + + // This is exactly the v1 README usage — snake_case events, `this.login_authorized(true)`. + const server = gps.server({ debug: false, port: 0, device_adapter: 'TK103' }, function (device: LegacyDevice) { + device.on('login_request', function (this: LegacyDevice, _deviceId: string, _msgParts: unknown) { + this.login_authorized(true); + }); + device.on('ping', function (data: unknown) { + pings.push(data); + }); + }); + cleanups.push(() => server.v2.close()); + + const address = await new Promise<{ port: number }>((resolve) => server.v2.once('listening', resolve)); + return { server, port: address.port, pings }; +} + +describe('v1 compatibility layer', () => { + it('runs the v1 README example unchanged (login + ping, snake_case events)', async () => { + const { server, port, pings } = await startLegacyServer(); + const client = await connect(port); + + const ack = new Promise((resolve) => client.once('data', resolve)); + client.write(LOGIN); + expect((await ack).toString()).toBe('(012341234123AP05)'); + + client.write(PING); + await vi.waitFor(() => expect(pings.length).toBe(1)); + + const data = pings[0] as Record; + expect(data.latitude).toBeCloseTo(-33.5071467, 6); + expect(data.longitude).toBeCloseTo(-70.6141967, 6); + expect(data.from_cmd).toBe('BR00'); + + // v1 server API + const found = server.find_device('012341234123'); + expect(found).not.toBe(false); + expect((found as LegacyDevice).getUID()).toBe('012341234123'); + expect((found as LegacyDevice).loged).toBe(true); + expect(server.find_device('nope')).toBe(false); + server.setDebug(true); + expect(server.getDebug()).toBe(true); + }); + + it('exposes the v1 entry points (server function, version)', () => { + expect(typeof gps.server).toBe('function'); + expect(typeof gps.version).toBe('string'); + }); + + it('emits the DeprecationWarning exactly once per process', async () => { + // Fresh module registry so this test observes the first legacy use, + // regardless of what earlier tests in this worker already triggered. + vi.resetModules(); + const emitWarning = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const freshGps = await import('../../src/index.js'); + + const first = freshGps.server({ port: 0, device_adapter: 'TK103' }); + const second = freshGps.server({ port: 0, device_adapter: 'TK103' }); + cleanups.push(() => first.v2.close()); + cleanups.push(() => second.v2.close()); + + const deprecations = emitWarning.mock.calls.filter((args) => (args[1] as unknown) === 'DeprecationWarning'); + expect(deprecations.length).toBe(1); + }); + + it('rejects unknown adapter names like v1 did', () => { + expect(() => gps.server({ device_adapter: 'NOPE-3000', port: 0 })).toThrow(/doesn't exist/); + }); + + it('wraps legacy v1 custom adapter modules (parse_data / get_ping_data contract)', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const authorized: string[] = []; + + // A minimal v1-style adapter module, as documented in the v1 README. + const legacyModule = { + protocol: 'TEST1', + model_name: 'TEST-V1', + adapter: function (device: { uid: string | undefined; send: (data: string) => void }) { + return { + parse_data(data: Buffer) { + const text = data.toString(); + const [, id = '', action = '', payload = ''] = text.split('|'); + return { device_id: id, cmd: action, action, data: payload }; + }, + authorize() { + authorized.push('yes'); + device.send('OK'); + }, + get_ping_data(parts: { data?: string }) { + const [lat = '0', lng = '0'] = (parts.data ?? '').split(';'); + return { latitude: Number(lat), longitude: Number(lng), time: new Date(0) }; + }, + }; + }, + }; + + const pings: unknown[] = []; + const server = gps.server({ port: 0, device_adapter: legacyModule }, (device) => { + device.on('login_request', function (this: LegacyDevice) { + this.login_authorized(true); + }); + device.on('ping', (data: unknown) => pings.push(data)); + }); + cleanups.push(() => server.v2.close()); + const { port } = await new Promise<{ port: number }>((resolve) => server.v2.once('listening', resolve)); + + const client = await connect(port); + const ack = new Promise((resolve) => client.once('data', resolve)); + client.write('|dev-9|login_request|'); + expect((await ack).toString()).toBe('OK'); + expect(authorized).toEqual(['yes']); + + client.write('|dev-9|ping|-33.5;-70.6'); + await vi.waitFor(() => expect(pings.length).toBe(1)); + expect((pings[0] as { latitude: number }).latitude).toBeCloseTo(-33.5); + }); +}); diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts new file mode 100644 index 0000000..b4f6fac --- /dev/null +++ b/test/integration/server.test.ts @@ -0,0 +1,136 @@ +import type net from 'node:net'; +import { once } from 'node:events'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createServer, type GpsServer } from '../../src/server.js'; +import type { Device } from '../../src/device.js'; +import type { GpsPosition } from '../../src/types.js'; +import { adapters } from '../../src/adapters/index.js'; +import { connect, TK103_DEVICE_ID, TK103_LOGIN, TK103_PING } from '../helpers/tcp.js'; + +const servers: GpsServer[] = []; +const sockets: net.Socket[] = []; + +afterEach(async () => { + for (const socket of sockets.splice(0)) { + socket.destroy(); + } + for (const server of servers.splice(0)) { + await server.close(); + } +}); + +async function startServer(options: Partial[0]> = {}) { + const server = createServer({ adapter: adapters.TK103, port: 0, ...options }); + servers.push(server); + const devices: Device[] = []; + server.on('connection', (device) => { + devices.push(device); + device.on('loginRequest', () => device.acceptLogin()); + }); + const address = await server.listen(); + return { server, port: address.port, devices }; +} + +const track = (socket: net.Socket) => sockets.push(socket); + +describe('GpsServer integration (real TCP)', () => { + it('handles login → ack → ping end to end', async () => { + const { server, port, devices } = await startServer(); + const client = await connect(port, track); + + const ack = once(client, 'data'); + client.write(TK103_LOGIN); + expect(String((await ack)[0])).toBe(`(${TK103_DEVICE_ID}AP05)`); + + const device = devices[0]!; + expect(device.isAuthenticated).toBe(true); + expect(device.id).toBe(TK103_DEVICE_ID); + expect(server.getDevice(TK103_DEVICE_ID)).toBe(device); + + const ping = once(device, 'ping'); + client.write(TK103_PING); + const [position] = (await ping) as [GpsPosition]; + expect(position.latitude).toBeCloseTo(-33.5071467, 6); + }); + + it('reassembles positions that arrive fragmented over TCP', async () => { + const { port, devices } = await startServer(); + const client = await connect(port, track); + + client.write(TK103_LOGIN); + await once(client, 'data'); + + const device = devices[0]!; + const ping = once(device, 'ping'); + + // One position split into 3 chunks plus the start of the next packet. + client.write(TK103_PING.slice(0, 10)); + client.write(TK103_PING.slice(10, 40)); + client.write(TK103_PING.slice(40) + TK103_PING.slice(0, 5)); + const [position] = (await ping) as [GpsPosition]; + expect(position.latitude).toBeCloseTo(-33.5071467, 6); + }); + + it('discards data sent before login and asks nothing of the process', async () => { + const { port, devices } = await startServer(); + const client = await connect(port, track); + client.write(TK103_PING); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(devices[0]!.isAuthenticated).toBe(false); + }); + + it('survives an abrupt connection reset (issue #12)', async () => { + const { port, devices } = await startServer(); + + const client = await connect(port, track); + client.write(TK103_LOGIN); + await once(client, 'data'); + // RST instead of FIN: this used to crash the whole process in v1. + // (manual promise: events.once() would reject on the device's 'error' event) + const disconnected = new Promise((resolve) => devices[0]!.once('disconnect', resolve)); + client.resetAndDestroy(); + await disconnected; + + // The server keeps accepting connections. + const client2 = await connect(port, track); + const ack = once(client2, 'data'); + client2.write(TK103_LOGIN); + expect(String((await ack)[0])).toBe(`(${TK103_DEVICE_ID}AP05)`); + }); + + it('destroys idle connections after connectionTimeoutMs (issue #31)', async () => { + const { server, port, devices } = await startServer({ connectionTimeoutMs: 100 }); + const client = await connect(port, track); + client.write(TK103_LOGIN); + await once(client, 'data'); + expect(server.devices.size).toBe(1); + + await once(devices[0]!, 'timeout'); + await once(server, 'disconnect'); + expect(server.devices.size).toBe(0); + }); + + it('emits parseError instead of crashing on garbage frames', async () => { + const { port, devices } = await startServer(); + const client = await connect(port, track); + client.write(TK103_LOGIN); + await once(client, 'data'); + + const parseError = once(devices[0]!, 'parseError'); + client.write('(nocommandhere-longer-than-12)'); + const [error] = (await parseError) as [Error]; + expect(error.name).toBe('PacketParseError'); + }); + + it('sendTo() reaches the right device', async () => { + const { server, port } = await startServer(); + const client = await connect(port, track); + client.write(TK103_LOGIN); + await once(client, 'data'); + + const received = once(client, 'data'); + expect(server.sendTo(TK103_DEVICE_ID, 'hello')).toBe(true); + expect(String((await received)[0])).toBe('hello'); + expect(server.sendTo('unknown-id', 'hello')).toBe(false); + }); +}); diff --git a/test/unit/crc.test.ts b/test/unit/crc.test.ts new file mode 100644 index 0000000..4fe1069 --- /dev/null +++ b/test/unit/crc.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { crc16X25 } from '../../src/lib/crc.js'; + +describe('crc16X25', () => { + it('matches the standard X-25 check value', () => { + expect(crc16X25(Buffer.from('123456789', 'ascii'))).toBe(0x906e); + }); + + it('matches the GT06/GK309 login response example from the official Concox doc', () => { + // Response 78 78 05 01 00 01 D9 DC 0D 0A → CRC over 05 01 00 01 = 0xD9DC + expect(crc16X25(Buffer.from('05010001', 'hex'))).toBe(0xd9dc); + }); + + it('matches the GK309 GPS packet example from the official Concox doc', () => { + expect(crc16X25(Buffer.from('19100B03110A100FCF027AC8570C4657350014000001000452830D0A'.slice(0, -8), 'hex'))).toBe( + 0x5283, + ); + }); + + it('matches the GK309 GPS ack example from the official Concox doc', () => { + expect(crc16X25(Buffer.from('05100004', 'hex'))).toBe(0x5138); + }); +}); diff --git a/test/unit/framers.test.ts b/test/unit/framers.test.ts new file mode 100644 index 0000000..d021dd2 --- /dev/null +++ b/test/unit/framers.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { DelimiterFramer } from '../../src/framing/delimiter-framer.js'; +import { LengthPrefixedFramer } from '../../src/framing/length-prefixed-framer.js'; +import { GT06_FRAME_MARKERS } from '../../src/adapters/gt06.js'; + +/** Feed `stream` split at every possible byte position and assert identical frames. */ +function assertAllSplits(makeFramer: () => { push(chunk: Buffer): Buffer[] }, stream: Buffer, expected: Buffer[]) { + for (let splitAt = 1; splitAt < stream.length; splitAt++) { + const framer = makeFramer(); + const frames = [...framer.push(stream.subarray(0, splitAt)), ...framer.push(stream.subarray(splitAt))]; + expect(frames.map((f) => f.toString('hex'))).toEqual(expected.map((f) => f.toString('hex'))); + } +} + +describe('DelimiterFramer', () => { + const frameA = Buffer.from('(012341234123BR00ABC)'); + const frameB = Buffer.from('(012341234123BP05XYZ)'); + + it('emits one frame per packet when packets arrive whole', () => { + const framer = new DelimiterFramer({ start: 0x28, end: 0x29 }); + expect(framer.push(frameA)).toEqual([frameA]); + }); + + it('handles coalesced packets in a single chunk', () => { + const framer = new DelimiterFramer({ start: 0x28, end: 0x29 }); + expect(framer.push(Buffer.concat([frameA, frameB]))).toEqual([frameA, frameB]); + }); + + it('reassembles fragmented packets split at every byte position', () => { + assertAllSplits( + () => new DelimiterFramer({ start: 0x28, end: 0x29 }), + Buffer.concat([frameA, frameB]), + [frameA, frameB], + ); + }); + + it('discards garbage between frames', () => { + const framer = new DelimiterFramer({ start: 0x28, end: 0x29 }); + const stream = Buffer.concat([Buffer.from('\r\nnoise'), frameA, Buffer.from('\r\n'), frameB, Buffer.from('..')]); + expect(framer.push(stream)).toEqual([frameA, frameB]); + }); + + it('re-syncs when an unterminated frame exceeds maxFrameLength', () => { + const framer = new DelimiterFramer({ start: 0x28, end: 0x29, maxFrameLength: 8 }); + expect(framer.push(Buffer.from('(waaaaaaaaaaaaaaytoolong'))).toEqual([]); + expect(framer.push(Buffer.from('(ok)'))).toEqual([Buffer.from('(ok)')]); + }); + + it('drops oversized frames even when properly terminated', () => { + const framer = new DelimiterFramer({ start: 0x28, end: 0x29, maxFrameLength: 8 }); + const oversized = Buffer.from('(waaaaaaaaaytoolong)'); + expect(framer.push(Buffer.concat([oversized, Buffer.from('(ok)')]))).toEqual([Buffer.from('(ok)')]); + }); +}); + +describe('LengthPrefixedFramer', () => { + const login = Buffer.from('78780F01025241903071152410050001F3D70D0A', 'hex'); + const gps = Buffer.from('787819100B03110A100FCF027AC8570C4657350014000001000452830D0A', 'hex'); + + it('emits one frame per packet when packets arrive whole', () => { + const framer = new LengthPrefixedFramer({ markers: GT06_FRAME_MARKERS }); + expect(framer.push(login)).toEqual([login]); + }); + + it('handles coalesced packets in a single chunk', () => { + const framer = new LengthPrefixedFramer({ markers: GT06_FRAME_MARKERS }); + expect(framer.push(Buffer.concat([login, gps]))).toEqual([login, gps]); + }); + + it('reassembles fragmented packets split at every byte position', () => { + assertAllSplits( + () => new LengthPrefixedFramer({ markers: GT06_FRAME_MARKERS }), + Buffer.concat([login, gps]), + [login, gps], + ); + }); + + it('discards garbage before a marker', () => { + const framer = new LengthPrefixedFramer({ markers: GT06_FRAME_MARKERS }); + expect(framer.push(Buffer.concat([Buffer.from('deadbeef', 'hex'), gps]))).toEqual([gps]); + }); + + it('re-syncs after a corrupt length field', () => { + const framer = new LengthPrefixedFramer({ markers: GT06_FRAME_MARKERS, maxFrameLength: 64 }); + // 0x7878 followed by an absurd length, then a valid frame. + expect(framer.push(Buffer.concat([Buffer.from('7878ff', 'hex'), gps]))).toEqual([gps]); + }); + + it('waits for more data when the length field itself is incomplete', () => { + const framer = new LengthPrefixedFramer({ markers: GT06_FRAME_MARKERS }); + expect(framer.push(Buffer.from('7878', 'hex'))).toEqual([]); + expect(framer.push(login.subarray(2))).toEqual([login]); + }); +}); diff --git a/test/unit/geo.test.ts b/test/unit/geo.test.ts new file mode 100644 index 0000000..fb3d065 --- /dev/null +++ b/test/unit/geo.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { getDistance, minuteToDecimal } from '../../src/lib/geo.js'; + +describe('minuteToDecimal', () => { + it('converts DDMM.MMMM to decimal degrees', () => { + expect(minuteToDecimal(3330.4288, 'S')).toBeCloseTo(-33.5071467, 6); + expect(minuteToDecimal(7036.8518, 'W')).toBeCloseTo(-70.6141967, 6); + expect(minuteToDecimal(2235.1777, 'N')).toBeCloseTo(22.586295, 6); + expect(minuteToDecimal(11357.8913, 'E')).toBeCloseTo(113.964855, 6); + }); + + it('defaults to the northern/eastern hemisphere', () => { + expect(minuteToDecimal(2235.1777)).toBeCloseTo(22.586295, 6); + }); + + it('returns NaN for malformed input instead of a bogus coordinate', () => { + expect(minuteToDecimal(1260.0, 'N')).toBeNaN(); // minutes >= 60 + expect(minuteToDecimal(9199.0, 'N')).toBeNaN(); // > 90 degrees of latitude + expect(minuteToDecimal(2235.1777, 'X')).toBeNaN(); // unknown hemisphere + expect(minuteToDecimal(Number.NaN, 'N')).toBeNaN(); + }); +}); + +describe('getDistance', () => { + it('measures ~0 for the same point', () => { + expect(getDistance({ lat: -33.45, lng: -70.66 }, { lat: -33.45, lng: -70.66 })).toBe(0); + }); + + it('measures Santiago-Valparaíso at roughly 100km', () => { + const distance = getDistance({ lat: -33.4489, lng: -70.6693 }, { lat: -33.0472, lng: -71.6127 }); + expect(distance).toBeGreaterThan(90_000); + expect(distance).toBeLessThan(110_000); + }); +}); diff --git a/test/unit/gt02a.test.ts b/test/unit/gt02a.test.ts new file mode 100644 index 0000000..05daa3b --- /dev/null +++ b/test/unit/gt02a.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { Gt02aAdapter } from '../../src/adapters/gt02a.js'; +import { fakeDevice, sentAsStrings } from '../helpers/fake-device.js'; + +// Fixtures built from the GT02 protocol spec (same position payload as the +// Concox doc example: 2011-03-17 10:16:15, 23.111728N 114.409131E). +const LOGIN = Buffer.from('68680D040401234567890123450001' + '1A' + '0D0A', 'hex'); +const PING = Buffer.from( + '68681E0404012345678901234500011' + '0' + '0B03110A100F' + '027AC857' + '0C465735' + '35' + '0014' + '0D0A', + 'hex', +); + +function makeAdapter() { + const { device, sent } = fakeDevice(); + return { adapter: new Gt02aAdapter(device), sent }; +} + +describe('Gt02aAdapter', () => { + it('parses a login request (0x1a) and extracts the IMEI', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(LOGIN); + expect(packet.action).toBe('loginRequest'); + expect(packet.deviceId).toBe('123456789012345'); + }); + + it('acks an accepted login with Th+0x1a', () => { + const { adapter, sent } = makeAdapter(); + adapter.parsePacket(LOGIN); + adapter.authorize(); + expect(sentAsStrings(sent)).toEqual(['54681a0d0a']); + }); + + it('parses a position packet (0x10)', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(PING); + if (packet.action !== 'ping') throw new Error('expected ping'); + expect(packet.position.latitude).toBeCloseTo(23.111728, 5); + expect(packet.position.longitude).toBeCloseTo(114.409131, 5); + expect(packet.position.speed).toBe(0x35); + expect(packet.position.orientation).toBe(20); + expect(packet.position.time.toISOString()).toBe('2011-03-17T10:16:15.000Z'); + expect(packet.position.extra?.power).toBe(4); + }); + + it('ignores 0x7878 service frames', () => { + const { adapter } = makeAdapter(); + const clock = Buffer.from('787805 8A 0001 D9DC 0D0A'.replaceAll(' ', ''), 'hex'); + expect(adapter.parsePacket(clock).action).toBe('ignore'); + }); +}); diff --git a/test/unit/gt06.test.ts b/test/unit/gt06.test.ts new file mode 100644 index 0000000..222fee5 --- /dev/null +++ b/test/unit/gt06.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { Gt06Adapter, Gk309Adapter } from '../../src/adapters/gt06.js'; +import { fakeDevice, sentAsStrings } from '../helpers/fake-device.js'; + +// All fixtures come from Appendix B of the official Concox "GK309 Communication +// Protocol V1.8" PDF (attached to issue #26), which is the GT06 wire protocol. +const LOGIN = Buffer.from('78780F01025241903071152410050001F3D70D0A', 'hex'); +const LOGIN_ACK = '787805010001d9dc0d0a'; +const GPS = Buffer.from('787819100B03110A100FCF027AC8570C4657350014000001000452830D0A', 'hex'); +const GPS_ACK = '78780510000451380d0a'; +const HEARTBEAT = Buffer.from('78780A13000504000003F352940D0A', 'hex'); +const HEARTBEAT_ACK = '7878051303f317040d0a'; +const SOS_ALARM = Buffer.from( + '787825160B03110A1010CF027AC8450C4657410014000901CC00266A001E236006040001000A34620D0A', + 'hex', +); + +function makeAdapter() { + const { device, sent } = fakeDevice(); + return { adapter: new Gt06Adapter(device), sent }; +} + +describe('Gt06Adapter', () => { + it('parses a login request and extracts the IMEI', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(LOGIN); + expect(packet.action).toBe('loginRequest'); + expect(packet.deviceId).toBe('252419030711524'); + expect(packet.serial).toBe(1); + }); + + it('acks an accepted login echoing the received serial (doc example)', () => { + const { adapter, sent } = makeAdapter(); + const packet = adapter.parsePacket(LOGIN); + adapter.authorize(packet); + expect(sentAsStrings(sent)).toEqual([LOGIN_ACK]); + }); + + it('parses a GPS packet (0x10) per the doc example', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(GPS); + if (packet.action !== 'ping') throw new Error('expected ping'); + expect(packet.position.latitude).toBeCloseTo(23.111728, 5); + expect(packet.position.longitude).toBeCloseTo(114.409131, 5); + expect(packet.position.time.toISOString()).toBe('2011-03-17T10:16:15.000Z'); + expect(packet.position.satellites).toBe(15); + expect(packet.position.speed).toBe(0); + expect(packet.position.orientation).toBe(0); + expect(packet.position.valid).toBe(true); + expect(packet.serial).toBe(4); + }); + + it('acks a GPS packet with the doc example response', () => { + const { adapter, sent } = makeAdapter(); + const packet = adapter.parsePacket(GPS); + adapter.ackPing(packet); + expect(sentAsStrings(sent)).toEqual([GPS_ACK]); + }); + + it('parses and acks a heartbeat (0x13) with the doc example response', () => { + const { adapter, sent } = makeAdapter(); + const packet = adapter.parsePacket(HEARTBEAT); + expect(packet.action).toBe('heartbeat'); + adapter.ackHeartbeat(packet); + expect(sentAsStrings(sent)).toEqual([HEARTBEAT_ACK]); + }); + + it('detects the SOS alarm from the terminal-info byte (0x16)', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(SOS_ALARM); + if (packet.action !== 'alarm') throw new Error('expected alarm'); + expect(packet.alarm.code).toBe('sos'); + expect(packet.position?.latitude).toBeCloseTo(23.111718, 5); + expect(packet.position?.extra?.mcc).toBe(460); + expect(packet.position?.extra?.voltage).toBe(6); + expect(packet.position?.extra?.gsmSignal).toBe(4); + }); + + it('rejects frames with a bad CRC', () => { + const { adapter } = makeAdapter(); + const corrupted = Buffer.from(GPS); + corrupted.writeUInt8(corrupted.readUInt8(10) ^ 0xff, 10); + expect(() => adapter.parsePacket(corrupted)).toThrow(/CRC/); + }); + + it('GK309 uses the same protocol under its own model name', () => { + const { device } = fakeDevice(); + const adapter = new Gk309Adapter(device); + expect(Gk309Adapter.modelName).toBe('GK309'); + expect(adapter.parsePacket(LOGIN).deviceId).toBe('252419030711524'); + }); +}); diff --git a/test/unit/st901.test.ts b/test/unit/st901.test.ts new file mode 100644 index 0000000..79f89d3 --- /dev/null +++ b/test/unit/st901.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { St901Adapter } from '../../src/adapters/st901.js'; +import { fakeDevice, sentAsStrings } from '../helpers/fake-device.js'; + +// Real packets from the Traccar H02 protocol test suite and the HuaSunTeK spec. +const V1_FULL = Buffer.from( + '*HQ,4970105243,V1,104000,A,2235.1777,N,11357.8913,E,000.27,235,130721,FFFFFBFF,460,11,d18e105,7752,6#', +); +const V1_SPACES = Buffer.from('*HQ,865205035331981,V1,132926,A,1935.3933,N,07920.4134,E, 3.34,342,280519,FFFFFFFF#'); +const HTBT = Buffer.from('*HQ,135790246811220,HTBT,100#'); +const ALRM_SOS = Buffer.from('*HQ,4970105243,ALRM,1,104000,A,2235.1777,N,11357.8913,E,000.00,000,130721#'); + +function makeAdapter() { + const { device, sent } = fakeDevice(); + return { adapter: new St901Adapter(device), sent }; +} + +describe('St901Adapter (H02)', () => { + it('does not require a login handshake', () => { + expect(St901Adapter.requiresLogin).toBe(false); + }); + + it('parses a V1 position message (Traccar fixture)', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(V1_FULL); + if (packet.action !== 'ping') throw new Error('expected ping'); + expect(packet.deviceId).toBe('4970105243'); + expect(packet.position.latitude).toBeCloseTo(22.586295, 6); + expect(packet.position.longitude).toBeCloseTo(113.964855, 6); + expect(packet.position.time.toISOString()).toBe('2021-07-13T10:40:00.000Z'); + expect(packet.position.speed).toBeCloseTo(0.27 * 1.852, 4); + expect(packet.position.orientation).toBe(235); + expect(packet.position.valid).toBe(true); + // status FFFFFBFF → bit10 = 0 → ACC/ignition off + expect(packet.position.extra?.ignition).toBe(false); + }); + + it('tolerates space-padded fields and 15-digit IMEIs', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(V1_SPACES); + if (packet.action !== 'ping') throw new Error('expected ping'); + expect(packet.deviceId).toBe('865205035331981'); + expect(packet.position.latitude).toBeCloseTo(19.589888, 5); + expect(packet.position.longitude).toBeCloseTo(79.340223, 5); + expect(packet.position.speed).toBeCloseTo(3.34 * 1.852, 4); + expect(packet.position.time.toISOString()).toBe('2019-05-28T13:29:26.000Z'); + // status FFFFFFFF → bit10 = 1 → ignition on + expect(packet.position.extra?.ignition).toBe(true); + }); + + it('echoes HTBT heartbeats (required by newer ST-901 firmwares)', () => { + const { adapter, sent } = makeAdapter(); + const packet = adapter.parsePacket(HTBT); + expect(packet.action).toBe('heartbeat'); + adapter.ackHeartbeat(packet); + expect(sentAsStrings(sent)).toEqual(['*HQ,135790246811220,HTBT#']); + }); + + it('parses ALRM alarm messages', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(ALRM_SOS); + if (packet.action !== 'alarm') throw new Error('expected alarm'); + expect(packet.alarm.code).toBe('sos'); + }); + + it('ignores V4 command echoes', () => { + const { adapter } = makeAdapter(); + expect(adapter.parsePacket(Buffer.from('*HQ,4970105243,V4,V1,20210713104000#')).action).toBe('ignore'); + }); +}); diff --git a/test/unit/tk103.test.ts b/test/unit/tk103.test.ts new file mode 100644 index 0000000..5bbaeb4 --- /dev/null +++ b/test/unit/tk103.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { Tk103Adapter } from '../../src/adapters/tk103.js'; +import { fakeDevice, sentAsStrings } from '../helpers/fake-device.js'; + +// Packet examples from the original protocol docs / v1 readme. +const LOGIN = Buffer.from( + '(012341234123BP05000012341234123140607A3330.4288S07036.8518W019.2230104172.3900000000L00019C2C)', +); +const PING = Buffer.from( + '(012341234123BR00140607A3330.4288S07036.8518W019.2230104172.3900000000L00019C2C)', +); +const ALARM = Buffer.from( + '(012341234123BO012140607A3330.4288S07036.8518W019.2230104172.3900000000L00019C2C)', +); +const HANDSHAKE = Buffer.from('(012341234123BP00HSO)'); + +function makeAdapter(id = '012341234123') { + const { device, sent } = fakeDevice(id); + return { adapter: new Tk103Adapter(device), sent }; +} + +describe('Tk103Adapter', () => { + it('parses a login request (BP05)', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(LOGIN); + expect(packet.action).toBe('loginRequest'); + expect(packet.deviceId).toBe('012341234123'); + expect(packet.cmd).toBe('BP05'); + }); + + it('acknowledges an accepted login with AP05', () => { + const { adapter, sent } = makeAdapter(); + const packet = adapter.parsePacket(LOGIN); + adapter.authorize(packet); + expect(sentAsStrings(sent)).toEqual(['(012341234123AP05)']); + }); + + it('parses a position packet (BR00)', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(PING); + if (packet.action !== 'ping') throw new Error('expected ping'); + expect(packet.position.latitude).toBeCloseTo(-33.5071467, 6); + expect(packet.position.longitude).toBeCloseTo(-70.6141967, 6); + expect(packet.position.speed).toBeCloseTo(19.2); + expect(packet.position.orientation).toBeCloseTo(172.39); + expect(packet.position.valid).toBe(true); + expect(packet.position.mileage).toBe(Number.parseInt('00019C2C', 16)); + expect(packet.position.time.toISOString()).toBe('2014-06-07T23:01:04.000Z'); + }); + + it('parses an alarm packet (BO01) with its position and acks it', () => { + const { adapter, sent } = makeAdapter(); + const packet = adapter.parsePacket(ALARM); + if (packet.action !== 'alarm') throw new Error('expected alarm'); + expect(packet.alarm.code).toBe('sos'); + expect(packet.position?.latitude).toBeCloseTo(-33.5071467, 6); + adapter.ackAlarm(packet); + expect(sentAsStrings(sent)).toEqual(['(012341234123AS012)']); + }); + + it('replies to the BP00 handshake', () => { + const { adapter, sent } = makeAdapter(); + const packet = adapter.parsePacket(HANDSHAKE); + expect(packet.action).toBe('other'); + adapter.handleCommand(packet); + expect(sentAsStrings(sent)).toEqual(['(012341234123AP01HSO)']); + }); + + it('rejects frames without a command', () => { + const { adapter } = makeAdapter(); + expect(() => adapter.parsePacket(Buffer.from('(garbage-without-cmd)'))).toThrow(); + }); + + it('encodes setRefreshInterval as AR00', () => { + const { adapter, sent } = makeAdapter(); + adapter.setRefreshInterval(30, 3660); + expect(sentAsStrings(sent)).toEqual(['(012341234123AR00001e0101)']); + }); +}); diff --git a/test/unit/tk510.test.ts b/test/unit/tk510.test.ts new file mode 100644 index 0000000..d23f69e --- /dev/null +++ b/test/unit/tk510.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { Tk510Adapter } from '../../src/adapters/tk510.js'; +import { crc16X25 } from '../../src/lib/crc.js'; +import { fakeDevice, sentAsStrings } from '../helpers/fake-device.js'; + +const DEVICE_ID_RAW = '0135790246811f'; // 7 BCD bytes, F-padded + +/** Build a TK510 frame: 4040 | totalLen(2) | id(7) | cmd(2) | data | crc(2) | 0d0a */ +function buildFrame(cmd: string, data: Buffer): Buffer { + const total = data.length + 17; + const body = Buffer.concat([ + Buffer.from('4040' + total.toString(16).padStart(4, '0') + DEVICE_ID_RAW + cmd, 'hex'), + data, + ]); + const crc = Buffer.alloc(2); + crc.writeUInt16BE(crc16X25(body), 0); + return Buffer.concat([body, crc, Buffer.from([0x0d, 0x0a])]); +} + +const LOGIN = buildFrame('5000', Buffer.alloc(0)); +const PING = buildFrame('9955', Buffer.from('052825,A,2239.4210,N,11400.8825,E,0.00,348,180814,0.0,E,7A', 'ascii')); +const ALARM = buildFrame('9999', Buffer.from('01', 'hex')); + +function makeAdapter() { + const { device, sent } = fakeDevice(); + return { adapter: new Tk510Adapter(device), sent }; +} + +describe('Tk510Adapter', () => { + it('parses a login request (0x5000) stripping the F padding from the id', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(LOGIN); + expect(packet.action).toBe('loginRequest'); + expect(packet.deviceId).toBe('0135790246811'); + }); + + it('acks an accepted login with 0x4000 + 01, CRC included', () => { + const { adapter, sent } = makeAdapter(); + const packet = adapter.parsePacket(LOGIN); + adapter.authorize(packet); + const [ack] = sentAsStrings(sent); + expect(ack).toMatch(new RegExp(`^40400012${DEVICE_ID_RAW}400001[0-9a-f]{4}0d0a$`)); + // The ack must carry a valid CRC over everything before it. + const buffer = Buffer.from(ack!, 'hex'); + expect(buffer.readUInt16BE(buffer.length - 4)).toBe(crc16X25(buffer.subarray(0, buffer.length - 4))); + }); + + it('parses a position packet (0x9955) with an RMC-like payload', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(PING); + if (packet.action !== 'ping') throw new Error('expected ping'); + expect(packet.position.latitude).toBeCloseTo(22.657017, 5); + expect(packet.position.longitude).toBeCloseTo(114.014708, 5); + expect(packet.position.speed).toBe(0); + expect(packet.position.orientation).toBe(348); + // RMC date is DDMMYY: 180814 = 2014-08-18 (v1 misread it as YYMMDD). + expect(packet.position.time.toISOString()).toBe('2014-08-18T05:28:25.000Z'); + expect(packet.position.valid).toBe(true); + }); + + it('parses alarm packets (0x9999)', () => { + const { adapter } = makeAdapter(); + const packet = adapter.parsePacket(ALARM); + if (packet.action !== 'alarm') throw new Error('expected alarm'); + expect(packet.alarm.code).toBe('sos'); + }); + + it('rejects frames with a bad CRC', () => { + const { adapter } = makeAdapter(); + const corrupted = Buffer.from(PING); + corrupted.writeUInt8(corrupted.readUInt8(20) ^ 0xff, 20); + expect(() => adapter.parsePacket(corrupted)).toThrow(/CRC/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2351226 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src", "test", "examples/simple.ts"] +}