@jetforge/zebra-bridge 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -0
- package/package.json +25 -0
- package/src/jetforge-bridge-client.js +297 -0
- package/src/logger.js +70 -0
- package/src/serial-scanner.js +187 -0
- package/src/server.js +96 -0
- package/src/worker.js +151 -0
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Zebra Bridge for JetForge
|
|
2
|
+
|
|
3
|
+
Bridge service for one Zebra handheld barcode scanner connected over a serial COM port. It reads each scan from the configured serial port and sends the scanned value to JetForge as the plain bridge command `barcode`.
|
|
4
|
+
|
|
5
|
+
The scanner does not need to send a newline. The bridge uses an inter-byte idle timeout to treat one burst of bytes as one scanned value.
|
|
6
|
+
|
|
7
|
+
## Running
|
|
8
|
+
|
|
9
|
+
Install dependencies:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
PowerShell example for the current scanner on `COM11`:
|
|
16
|
+
|
|
17
|
+
```powershell
|
|
18
|
+
$env:BRIDGE_TOKEN="jf_..."
|
|
19
|
+
$env:SERIAL_PATH="COM11"
|
|
20
|
+
npm start
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Supported environment variables:
|
|
24
|
+
|
|
25
|
+
| Variable | Default | Meaning |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| `BRIDGE_TOKEN` | required | JetForge bridge endpoint token |
|
|
28
|
+
| `SERIAL_PATH` | required | Serial port path, for example `COM11` |
|
|
29
|
+
| `SERIAL_BAUD_RATE` | `9600` | Serial baud rate |
|
|
30
|
+
|
|
31
|
+
All other bridge settings are fixed in code:
|
|
32
|
+
|
|
33
|
+
| Setting | Value |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| HTTP base URL | `https://my.jetforge.app` |
|
|
36
|
+
| WebSocket URL | `wss://my.jetforge.app/bridge-ws/` |
|
|
37
|
+
| Scan idle timeout | `75ms` |
|
|
38
|
+
| Scan max buffer size | `2048` bytes |
|
|
39
|
+
| Barcode queue max size | `500` |
|
|
40
|
+
| Barcode queue retry interval | `5000ms` |
|
|
41
|
+
| Log barcode values | `false` |
|
|
42
|
+
|
|
43
|
+
List detected serial ports:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm run list-ports
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Simulate one scan without opening a serial port:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
node src/server.js --simulate ABC123
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Bridge Protocol
|
|
56
|
+
|
|
57
|
+
The bridge authenticates to JetForge bridge websocket with:
|
|
58
|
+
|
|
59
|
+
```http
|
|
60
|
+
Authorization: Bearer jf_<endpoint_id>_<secret>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
After `bridge.init`, it sends periodic `heartbeat` messages. For each scan it sends:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"type": "barcode",
|
|
68
|
+
"payload": {
|
|
69
|
+
"value": "ABC123",
|
|
70
|
+
"time": 1781860000
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If the WebSocket is unavailable, the bridge falls back to `POST /ips/bridge/` with:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"command": "barcode",
|
|
80
|
+
"payload": {
|
|
81
|
+
"value": "ABC123",
|
|
82
|
+
"time": 1781860000
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Runtime diagnostics are appended to `logs/bridge-debug.log`. Bridge tokens are redacted. Barcode values are not logged.
|
|
88
|
+
|
|
89
|
+
## JetForge Server Update Needed
|
|
90
|
+
|
|
91
|
+
This repository does not modify JetForge. The Zebra endpoint on the JetForge side should handle the bridge command `barcode`, then publish the existing UI websocket event `scanner.barcode`.
|
|
92
|
+
|
|
93
|
+
Suggested shape for `Oms\Models\Bridge\Vendor\Zebra\Scanner`:
|
|
94
|
+
|
|
95
|
+
```php
|
|
96
|
+
public function receiveCommand(string $command, array $payload = []): array
|
|
97
|
+
{
|
|
98
|
+
if ($command === "barcode")
|
|
99
|
+
{
|
|
100
|
+
return $this->receiveBarcode($payload);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return parent::receiveCommand($command, $payload);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private function receiveBarcode(array $payload): array
|
|
107
|
+
{
|
|
108
|
+
$this->attendedAt = time();
|
|
109
|
+
$this->update();
|
|
110
|
+
|
|
111
|
+
$value = trim((string) ($payload["value"] ?? ""));
|
|
112
|
+
if ($value === "")
|
|
113
|
+
{
|
|
114
|
+
throw new \InvalidArgumentException("Barcode value is not specified");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
$time = (int) ($payload["time"] ?? time());
|
|
118
|
+
|
|
119
|
+
\Oms\Models\Bridge\Log::write($this, null, \Oms\Models\Bridge\LogDirection::Incoming, "barcode", [
|
|
120
|
+
"value" => $value,
|
|
121
|
+
"time" => $time,
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
$event = [
|
|
125
|
+
"type" => "scanner.barcode",
|
|
126
|
+
"farm_id" => $this->farmId,
|
|
127
|
+
"payload" => [
|
|
128
|
+
"value" => $value,
|
|
129
|
+
"scanner_id" => $this->id,
|
|
130
|
+
"time" => $time,
|
|
131
|
+
],
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
$json = json_encode($event, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
135
|
+
\__::$sql->query("NOTIFY ui_ws_event, " . \__::$sql->dbo->wrapScalar($json));
|
|
136
|
+
|
|
137
|
+
return [
|
|
138
|
+
"status" => "ok",
|
|
139
|
+
"value" => $value,
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The bridge command stays unscoped as `barcode`; only the UI websocket event remains `scanner.barcode`.
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jetforge/zebra-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zebra barcode scanner bridge for JetForge",
|
|
5
|
+
"main": "src/server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zebra-bridge": "src/server.js"
|
|
8
|
+
},
|
|
9
|
+
"author": "Smart Dynamics SIA",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node src/server.js",
|
|
17
|
+
"list-ports": "node src/server.js --list-ports"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@serialport/parser-inter-byte-timeout": "^12.0.0",
|
|
21
|
+
"axios": "^1.7.2",
|
|
22
|
+
"serialport": "^12.0.0",
|
|
23
|
+
"ws": "^8.18.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
|
|
4
|
+
const heartbeatInterval = 30000;
|
|
5
|
+
const reconnectMinDelay = 1000;
|
|
6
|
+
const reconnectMaxDelay = 30000;
|
|
7
|
+
|
|
8
|
+
export default class JetForgeBridgeClient {
|
|
9
|
+
constructor({token, httpBaseUrl, wsUrl, onCommand, onReady, logger}) {
|
|
10
|
+
if (!token) {
|
|
11
|
+
throw new Error("BRIDGE_TOKEN is required");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
this.token = token;
|
|
15
|
+
this.httpBaseUrl = httpBaseUrl;
|
|
16
|
+
this.wsUrl = wsUrl;
|
|
17
|
+
this.onCommand = onCommand;
|
|
18
|
+
this.onReady = onReady;
|
|
19
|
+
this.ws = null;
|
|
20
|
+
this.heartbeatTimer = null;
|
|
21
|
+
this.reconnectTimer = null;
|
|
22
|
+
this.reconnectDelay = reconnectMinDelay;
|
|
23
|
+
this.shouldReconnect = true;
|
|
24
|
+
this.logger = logger;
|
|
25
|
+
|
|
26
|
+
this.axiosInstance = axios.create({
|
|
27
|
+
baseURL: httpBaseUrl,
|
|
28
|
+
timeout: 30000,
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${token}`,
|
|
31
|
+
"Content-Type": "application/json"
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
start() {
|
|
37
|
+
this.connect();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
connect() {
|
|
41
|
+
if (this.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(this.ws.readyState)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.log("info", "endpoint.websocket.connecting", {
|
|
46
|
+
wsUrl: this.wsUrl
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.ws = new WebSocket(this.wsUrl, {
|
|
50
|
+
headers: {
|
|
51
|
+
Authorization: `Bearer ${this.token}`
|
|
52
|
+
},
|
|
53
|
+
handshakeTimeout: 15000
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.ws.on("open", () => {
|
|
57
|
+
this.reconnectDelay = reconnectMinDelay;
|
|
58
|
+
this.log("info", "endpoint.websocket.opened");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.ws.on("message", data => {
|
|
62
|
+
this.handleMessage(data).catch(error => {
|
|
63
|
+
this.log("error", "endpoint.websocket.message_handler_failed", {
|
|
64
|
+
error
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.ws.on("close", (code, reason) => {
|
|
70
|
+
const reasonText = reason?.toString?.() || "";
|
|
71
|
+
this.log("error", "endpoint.websocket.closed", {
|
|
72
|
+
code,
|
|
73
|
+
reason: reasonText
|
|
74
|
+
});
|
|
75
|
+
this.stopHeartbeat();
|
|
76
|
+
this.ws = null;
|
|
77
|
+
this.scheduleReconnect();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
this.ws.on("error", error => {
|
|
81
|
+
this.log("error", "endpoint.websocket.error", {
|
|
82
|
+
error
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async handleMessage(data) {
|
|
88
|
+
let message;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
message = JSON.parse(data.toString());
|
|
92
|
+
} catch (error) {
|
|
93
|
+
this.log("error", "endpoint.websocket.invalid_json", {
|
|
94
|
+
error
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (message.type === "bridge.init") {
|
|
100
|
+
this.log("info", "endpoint.initialized", {
|
|
101
|
+
endpointId: message.endpoint_id,
|
|
102
|
+
serverTime: message.time
|
|
103
|
+
});
|
|
104
|
+
this.startHeartbeat();
|
|
105
|
+
await this.onReady?.(message);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (message.type === "response") {
|
|
110
|
+
this.log("info", "endpoint.websocket.response", {
|
|
111
|
+
id: message.id ?? null
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (message.type === "error") {
|
|
117
|
+
this.log("error", "endpoint.websocket.backend_error", {
|
|
118
|
+
message: message.message || "unknown error",
|
|
119
|
+
id: message.id ?? null
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (message.type !== "command" || !message.id) {
|
|
125
|
+
this.log("info", "endpoint.websocket.service_message_ignored", {
|
|
126
|
+
type: message.type || "unknown"
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await this.onCommand?.(message);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
startHeartbeat() {
|
|
135
|
+
this.stopHeartbeat();
|
|
136
|
+
this.sendHeartbeat();
|
|
137
|
+
this.heartbeatTimer = setInterval(() => {
|
|
138
|
+
this.sendHeartbeat();
|
|
139
|
+
}, heartbeatInterval);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
stopHeartbeat() {
|
|
143
|
+
if (this.heartbeatTimer) {
|
|
144
|
+
clearInterval(this.heartbeatTimer);
|
|
145
|
+
this.heartbeatTimer = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
sendHeartbeat() {
|
|
150
|
+
this.sendOverWebSocket({
|
|
151
|
+
type: "heartbeat",
|
|
152
|
+
payload: {}
|
|
153
|
+
}).catch(error => {
|
|
154
|
+
this.log("error", "endpoint.heartbeat.failed", {
|
|
155
|
+
error
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
scheduleReconnect() {
|
|
161
|
+
if (!this.shouldReconnect || this.reconnectTimer) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const delay = this.reconnectDelay;
|
|
166
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, reconnectMaxDelay);
|
|
167
|
+
|
|
168
|
+
this.log("info", "endpoint.websocket.reconnect_scheduled", {
|
|
169
|
+
delay
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
this.reconnectTimer = setTimeout(() => {
|
|
173
|
+
this.reconnectTimer = null;
|
|
174
|
+
this.connect();
|
|
175
|
+
}, delay);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
sendOverWebSocket(message) {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
181
|
+
reject(new Error("JetForge bridge websocket is not open"));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.ws.send(JSON.stringify(message), error => {
|
|
186
|
+
if (error) {
|
|
187
|
+
reject(error);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async postBridgeCommand(command, payload = {}) {
|
|
197
|
+
const response = await this.axiosInstance.post("/ips/bridge/", {
|
|
198
|
+
command,
|
|
199
|
+
payload
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return response.data;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async sendBridgeMessage(type, payload = {}) {
|
|
206
|
+
try {
|
|
207
|
+
await this.sendOverWebSocket({
|
|
208
|
+
type,
|
|
209
|
+
payload
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
transport: "websocket"
|
|
214
|
+
};
|
|
215
|
+
} catch (websocketError) {
|
|
216
|
+
this.log("error", "endpoint.message.websocket_failed", {
|
|
217
|
+
type,
|
|
218
|
+
error: websocketError
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const response = await this.postBridgeCommand(type, payload);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
transport: "http",
|
|
225
|
+
response
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
sendBarcode(value, {time = Math.floor(Date.now() / 1000)} = {}) {
|
|
231
|
+
return this.sendBridgeMessage("barcode", {
|
|
232
|
+
value,
|
|
233
|
+
time
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async sendCommandStatus(type, commandId, payload = {}) {
|
|
238
|
+
const statusPayload = {
|
|
239
|
+
command_id: commandId,
|
|
240
|
+
...payload
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
await this.sendOverWebSocket({
|
|
245
|
+
type,
|
|
246
|
+
payload: statusPayload
|
|
247
|
+
});
|
|
248
|
+
} catch (error) {
|
|
249
|
+
this.log("error", "endpoint.command_status.websocket_failed", {
|
|
250
|
+
type,
|
|
251
|
+
commandId,
|
|
252
|
+
error
|
|
253
|
+
});
|
|
254
|
+
await this.postBridgeCommand(type, statusPayload);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
ackCommand(commandId) {
|
|
259
|
+
return this.sendCommandStatus("command.ack", commandId);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
failCommand(commandId, error) {
|
|
263
|
+
return this.sendCommandStatus("command.failed", commandId, {
|
|
264
|
+
error: error instanceof Error ? error.message : String(error || "Command failed")
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
close() {
|
|
269
|
+
this.shouldReconnect = false;
|
|
270
|
+
this.stopHeartbeat();
|
|
271
|
+
|
|
272
|
+
if (this.reconnectTimer) {
|
|
273
|
+
clearTimeout(this.reconnectTimer);
|
|
274
|
+
this.reconnectTimer = null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (this.ws) {
|
|
278
|
+
this.ws.close();
|
|
279
|
+
this.ws = null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
log(level, event, data = {}) {
|
|
284
|
+
if (this.logger) {
|
|
285
|
+
this.logger[level]?.(event, data);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const message = `${level.toUpperCase()} ${event}`;
|
|
290
|
+
if (level === "error") {
|
|
291
|
+
console.error(message, data);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
console.log(message, data);
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export default class Logger {
|
|
5
|
+
constructor({filePath = path.resolve(process.cwd(), "logs", "bridge-debug.log")} = {}) {
|
|
6
|
+
this.filePath = filePath;
|
|
7
|
+
fs.mkdirSync(path.dirname(this.filePath), {recursive: true});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
info(event, data = {}) {
|
|
11
|
+
this.write("info", event, data);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
error(event, data = {}) {
|
|
15
|
+
this.write("error", event, data);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
write(level, event, data = {}) {
|
|
19
|
+
const entry = {
|
|
20
|
+
...sanitize(data),
|
|
21
|
+
time: new Date().toISOString(),
|
|
22
|
+
level,
|
|
23
|
+
event
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const line = JSON.stringify(entry);
|
|
27
|
+
fs.appendFileSync(this.filePath, `${line}\n`);
|
|
28
|
+
|
|
29
|
+
const message = `[${entry.time}] ${level.toUpperCase()} ${event}`;
|
|
30
|
+
if (level === "error") {
|
|
31
|
+
console.error(message, sanitize(data));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(message, sanitize(data));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sanitize(value) {
|
|
40
|
+
if (value instanceof Error) {
|
|
41
|
+
return {
|
|
42
|
+
message: value.message,
|
|
43
|
+
stack: value.stack
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.map(sanitize);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!value || typeof value !== "object") {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result = {};
|
|
56
|
+
for (const [key, item] of Object.entries(value)) {
|
|
57
|
+
if (isSecretKey(key)) {
|
|
58
|
+
result[key] = "[redacted]";
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
result[key] = sanitize(item);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isSecretKey(key) {
|
|
69
|
+
return /token|secret|password|authorization/i.test(key);
|
|
70
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {InterByteTimeoutParser} from "@serialport/parser-inter-byte-timeout";
|
|
2
|
+
import {SerialPort} from "serialport";
|
|
3
|
+
|
|
4
|
+
const reconnectMinDelay = 1000;
|
|
5
|
+
const reconnectMaxDelay = 10000;
|
|
6
|
+
|
|
7
|
+
export default class SerialScanner {
|
|
8
|
+
constructor({path, baudRate, idleMs, maxBufferSize, onBarcode, logger, logBarcodeValue = false}) {
|
|
9
|
+
if (!path) {
|
|
10
|
+
throw new Error("SERIAL_PATH is required");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
this.path = path;
|
|
14
|
+
this.baudRate = baudRate;
|
|
15
|
+
this.idleMs = idleMs;
|
|
16
|
+
this.maxBufferSize = maxBufferSize;
|
|
17
|
+
this.onBarcode = onBarcode;
|
|
18
|
+
this.logger = logger;
|
|
19
|
+
this.logBarcodeValue = logBarcodeValue;
|
|
20
|
+
this.port = null;
|
|
21
|
+
this.parser = null;
|
|
22
|
+
this.reconnectTimer = null;
|
|
23
|
+
this.reconnectDelay = reconnectMinDelay;
|
|
24
|
+
this.shouldReconnect = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static listPorts() {
|
|
28
|
+
return SerialPort.list();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start() {
|
|
32
|
+
this.shouldReconnect = true;
|
|
33
|
+
this.open();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
open() {
|
|
37
|
+
if (this.port?.isOpen) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.log("info", "scanner.serial.opening", {
|
|
42
|
+
path: this.path,
|
|
43
|
+
baudRate: this.baudRate,
|
|
44
|
+
idleMs: this.idleMs
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const port = new SerialPort({
|
|
48
|
+
path: this.path,
|
|
49
|
+
baudRate: this.baudRate,
|
|
50
|
+
autoOpen: false
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.port = port;
|
|
54
|
+
this.parser = port.pipe(new InterByteTimeoutParser({
|
|
55
|
+
interval: this.idleMs,
|
|
56
|
+
maxBufferSize: this.maxBufferSize
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
this.parser.on("data", data => this.handleData(data));
|
|
60
|
+
|
|
61
|
+
port.on("error", error => {
|
|
62
|
+
this.log("error", "scanner.serial.error", {
|
|
63
|
+
path: this.path,
|
|
64
|
+
error
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
port.on("close", () => {
|
|
69
|
+
this.log("error", "scanner.serial.closed", {
|
|
70
|
+
path: this.path
|
|
71
|
+
});
|
|
72
|
+
this.port = null;
|
|
73
|
+
this.parser = null;
|
|
74
|
+
this.scheduleReconnect();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
port.open(error => {
|
|
78
|
+
if (error) {
|
|
79
|
+
this.log("error", "scanner.serial.open_failed", {
|
|
80
|
+
path: this.path,
|
|
81
|
+
error
|
|
82
|
+
});
|
|
83
|
+
this.port = null;
|
|
84
|
+
this.parser = null;
|
|
85
|
+
this.scheduleReconnect();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.reconnectDelay = reconnectMinDelay;
|
|
90
|
+
this.log("info", "scanner.serial.opened", {
|
|
91
|
+
path: this.path,
|
|
92
|
+
baudRate: this.baudRate
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
handleData(data) {
|
|
98
|
+
const value = normalizeBarcode(data);
|
|
99
|
+
|
|
100
|
+
if (!value) {
|
|
101
|
+
this.log("info", "scanner.barcode.empty_ignored", {
|
|
102
|
+
byteLength: data.length
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.log("info", "scanner.barcode.read", {
|
|
108
|
+
byteLength: data.length,
|
|
109
|
+
length: value.length,
|
|
110
|
+
...(this.logBarcodeValue ? {value} : {})
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
Promise.resolve(this.onBarcode?.(value, {
|
|
114
|
+
byteLength: data.length
|
|
115
|
+
})).catch(error => {
|
|
116
|
+
this.log("error", "scanner.barcode.handler_failed", {
|
|
117
|
+
error
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
scheduleReconnect() {
|
|
123
|
+
if (!this.shouldReconnect || this.reconnectTimer) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const delay = this.reconnectDelay;
|
|
128
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, reconnectMaxDelay);
|
|
129
|
+
|
|
130
|
+
this.log("info", "scanner.serial.reconnect_scheduled", {
|
|
131
|
+
path: this.path,
|
|
132
|
+
delay
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
this.reconnectTimer = setTimeout(() => {
|
|
136
|
+
this.reconnectTimer = null;
|
|
137
|
+
this.open();
|
|
138
|
+
}, delay);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
close() {
|
|
142
|
+
this.shouldReconnect = false;
|
|
143
|
+
|
|
144
|
+
if (this.reconnectTimer) {
|
|
145
|
+
clearTimeout(this.reconnectTimer);
|
|
146
|
+
this.reconnectTimer = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!this.port) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const port = this.port;
|
|
154
|
+
this.port = null;
|
|
155
|
+
this.parser = null;
|
|
156
|
+
|
|
157
|
+
if (port.isOpen) {
|
|
158
|
+
port.close();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
port.destroy?.();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
log(level, event, data = {}) {
|
|
166
|
+
if (this.logger) {
|
|
167
|
+
this.logger[level]?.(event, data);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const message = `${level.toUpperCase()} ${event}`;
|
|
172
|
+
if (level === "error") {
|
|
173
|
+
console.error(message, data);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(message, data);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function normalizeBarcode(data) {
|
|
182
|
+
return data
|
|
183
|
+
.toString("utf8")
|
|
184
|
+
.replace(/\0/g, "")
|
|
185
|
+
.replace(/[\r\n]+$/g, "")
|
|
186
|
+
.trim();
|
|
187
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import Worker from "./worker.js";
|
|
4
|
+
import SerialScanner from "./serial-scanner.js";
|
|
5
|
+
import JetForgeBridgeClient from "./jetforge-bridge-client.js";
|
|
6
|
+
import Logger from "./logger.js";
|
|
7
|
+
|
|
8
|
+
const httpBaseUrl = "https://my.jetforge.app";
|
|
9
|
+
const wsUrl = "wss://my.jetforge.app/bridge-ws/";
|
|
10
|
+
const defaultSerialBaudRate = 9600;
|
|
11
|
+
const scanIdleMs = 75;
|
|
12
|
+
const scanMaxBufferSize = 2048;
|
|
13
|
+
const queueMaxSize = 500;
|
|
14
|
+
const queueRetryMs = 5000;
|
|
15
|
+
const logBarcodeValue = false;
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
if (args.includes("--list-ports")) {
|
|
20
|
+
const ports = await SerialScanner.listPorts();
|
|
21
|
+
console.log(JSON.stringify(ports, null, 2));
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (args.includes("--simulate")) {
|
|
26
|
+
const value = args[args.indexOf("--simulate") + 1];
|
|
27
|
+
if (!value) {
|
|
28
|
+
console.error("Usage: zebra-bridge --simulate <barcode-value>");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const settings = readSettings({requireSerial: false});
|
|
33
|
+
const logger = new Logger();
|
|
34
|
+
const bridge = new JetForgeBridgeClient({
|
|
35
|
+
token: settings.token,
|
|
36
|
+
httpBaseUrl: settings.httpBaseUrl,
|
|
37
|
+
wsUrl: settings.wsUrl,
|
|
38
|
+
logger
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const result = await bridge.sendBarcode(value);
|
|
43
|
+
logger.info("scanner.barcode.simulated", {
|
|
44
|
+
transport: result.transport
|
|
45
|
+
});
|
|
46
|
+
} finally {
|
|
47
|
+
bridge.close();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const worker = new Worker(readSettings());
|
|
54
|
+
|
|
55
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
56
|
+
process.on(signal, () => {
|
|
57
|
+
worker.close();
|
|
58
|
+
process.exit(0);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readSettings({requireSerial = true} = {}) {
|
|
63
|
+
const token = process.env.BRIDGE_TOKEN;
|
|
64
|
+
const serialPath = process.env.SERIAL_PATH || "";
|
|
65
|
+
|
|
66
|
+
if (requireSerial && !serialPath) {
|
|
67
|
+
throw new Error("SERIAL_PATH is required, for example SERIAL_PATH=COM11");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
httpBaseUrl,
|
|
72
|
+
wsUrl,
|
|
73
|
+
token,
|
|
74
|
+
serialPath,
|
|
75
|
+
serialBaudRate: readInteger("SERIAL_BAUD_RATE", defaultSerialBaudRate),
|
|
76
|
+
scanIdleMs,
|
|
77
|
+
scanMaxBufferSize,
|
|
78
|
+
queueMaxSize,
|
|
79
|
+
queueRetryMs,
|
|
80
|
+
logBarcodeValue
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readInteger(name, defaultValue) {
|
|
85
|
+
const rawValue = process.env[name];
|
|
86
|
+
if (rawValue === undefined || rawValue === "") {
|
|
87
|
+
return defaultValue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const value = Number(rawValue);
|
|
91
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
92
|
+
throw new Error(`${name} must be a positive integer`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return value;
|
|
96
|
+
}
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import JetForgeBridgeClient from "./jetforge-bridge-client.js";
|
|
2
|
+
import Logger from "./logger.js";
|
|
3
|
+
import SerialScanner from "./serial-scanner.js";
|
|
4
|
+
|
|
5
|
+
export default class Worker {
|
|
6
|
+
constructor(settings) {
|
|
7
|
+
this.settings = settings;
|
|
8
|
+
this.logger = new Logger();
|
|
9
|
+
this.queue = [];
|
|
10
|
+
this.isFlushing = false;
|
|
11
|
+
this.retryTimer = null;
|
|
12
|
+
|
|
13
|
+
this.bridge = new JetForgeBridgeClient({
|
|
14
|
+
token: settings.token,
|
|
15
|
+
httpBaseUrl: settings.httpBaseUrl,
|
|
16
|
+
wsUrl: settings.wsUrl,
|
|
17
|
+
onCommand: message => this.handleCommand(message),
|
|
18
|
+
onReady: () => this.flushQueue(),
|
|
19
|
+
logger: this.logger
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
this.scanner = new SerialScanner({
|
|
23
|
+
path: settings.serialPath,
|
|
24
|
+
baudRate: settings.serialBaudRate,
|
|
25
|
+
idleMs: settings.scanIdleMs,
|
|
26
|
+
maxBufferSize: settings.scanMaxBufferSize,
|
|
27
|
+
logBarcodeValue: settings.logBarcodeValue,
|
|
28
|
+
onBarcode: value => this.handleBarcode(value),
|
|
29
|
+
logger: this.logger
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
this.logger.info("bridge.worker.started", {
|
|
33
|
+
httpBaseUrl: settings.httpBaseUrl,
|
|
34
|
+
wsUrl: settings.wsUrl,
|
|
35
|
+
serialPath: settings.serialPath,
|
|
36
|
+
serialBaudRate: settings.serialBaudRate,
|
|
37
|
+
scanIdleMs: settings.scanIdleMs,
|
|
38
|
+
scanMaxBufferSize: settings.scanMaxBufferSize
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
this.bridge.start();
|
|
42
|
+
this.scanner.start();
|
|
43
|
+
|
|
44
|
+
this.retryTimer = setInterval(() => {
|
|
45
|
+
this.flushQueue();
|
|
46
|
+
}, settings.queueRetryMs);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async handleCommand(message) {
|
|
50
|
+
const commandId = message.id;
|
|
51
|
+
const command = message.command;
|
|
52
|
+
|
|
53
|
+
this.logger.info("endpoint.command.received", {
|
|
54
|
+
command,
|
|
55
|
+
commandId
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await this.bridge.ackCommand(commandId);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this.logger.error("endpoint.command.ack_failed", {
|
|
62
|
+
command,
|
|
63
|
+
commandId,
|
|
64
|
+
error
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const error = new Error(`Unsupported Zebra bridge command: ${command}`);
|
|
69
|
+
this.logger.error("endpoint.command.failed", {
|
|
70
|
+
command,
|
|
71
|
+
commandId,
|
|
72
|
+
error
|
|
73
|
+
});
|
|
74
|
+
await this.bridge.failCommand(commandId, error);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
handleBarcode(value) {
|
|
78
|
+
const item = {
|
|
79
|
+
value,
|
|
80
|
+
time: Math.floor(Date.now() / 1000),
|
|
81
|
+
attempts: 0
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (this.queue.length >= this.settings.queueMaxSize) {
|
|
85
|
+
const dropped = this.queue.shift();
|
|
86
|
+
this.logger.error("scanner.barcode.queue_full_dropped_oldest", {
|
|
87
|
+
droppedLength: dropped?.value?.length ?? 0,
|
|
88
|
+
queueMaxSize: this.settings.queueMaxSize
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.queue.push(item);
|
|
93
|
+
this.logger.info("scanner.barcode.queued", {
|
|
94
|
+
length: value.length,
|
|
95
|
+
queueSize: this.queue.length,
|
|
96
|
+
...(this.settings.logBarcodeValue ? {value} : {})
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
this.flushQueue();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async flushQueue() {
|
|
103
|
+
if (this.isFlushing || this.queue.length === 0) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.isFlushing = true;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
while (this.queue.length > 0) {
|
|
111
|
+
const item = this.queue[0];
|
|
112
|
+
item.attempts += 1;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = await this.bridge.sendBarcode(item.value, {
|
|
116
|
+
time: item.time
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
this.queue.shift();
|
|
120
|
+
this.logger.info("scanner.barcode.sent", {
|
|
121
|
+
length: item.value.length,
|
|
122
|
+
attempts: item.attempts,
|
|
123
|
+
transport: result.transport,
|
|
124
|
+
queueSize: this.queue.length,
|
|
125
|
+
...(this.settings.logBarcodeValue ? {value: item.value} : {})
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
this.logger.error("scanner.barcode.send_failed", {
|
|
129
|
+
length: item.value.length,
|
|
130
|
+
attempts: item.attempts,
|
|
131
|
+
queueSize: this.queue.length,
|
|
132
|
+
error
|
|
133
|
+
});
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
this.isFlushing = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
close() {
|
|
143
|
+
if (this.retryTimer) {
|
|
144
|
+
clearInterval(this.retryTimer);
|
|
145
|
+
this.retryTimer = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.scanner.close();
|
|
149
|
+
this.bridge.close();
|
|
150
|
+
}
|
|
151
|
+
}
|