Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
29 changes: 29 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 }}
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
npm-debug.log
node_modules
node_modules
dist
coverage
*.log
5 changes: 0 additions & 5 deletions .jscsrc

This file was deleted.

103 changes: 103 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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.
185 changes: 185 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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<string, Device>
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<string, unknown>; // 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)
Loading
Loading