commit : working till bt info
This commit is contained in:
parent
b989d65e39
commit
a1db4b228b
207
bridge.js
207
bridge.js
@ -1,207 +0,0 @@
|
|||||||
// bridge.js — HTTP→TCP bridge for ABS_POLL using 512-byte packet framing (CommonJS)
|
|
||||||
const express = require("express");
|
|
||||||
const net = require("net");
|
|
||||||
|
|
||||||
// ---- ABS endpoint (yours) ----
|
|
||||||
const ABS_HOST = process.env.ABS_HOST || "192.0.0.14";
|
|
||||||
const ABS_PORT = Number(process.env.ABS_PORT || 7000);
|
|
||||||
|
|
||||||
// ---- constants (matching your code) ----
|
|
||||||
const ABS_POLL = 178; // Checks if the connection exists
|
|
||||||
const SUCCESS = 0;
|
|
||||||
|
|
||||||
const PKT_SIZE = 512;
|
|
||||||
const PAYLOAD_PER_PKT = PKT_SIZE - 1;
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// no global express.json(); we read the body safely per request
|
|
||||||
function readJsonBody(req) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const chunks = [];
|
|
||||||
req.on("data", (c) => chunks.push(c));
|
|
||||||
req.on("end", () => {
|
|
||||||
if (!chunks.length) return resolve({});
|
|
||||||
const txt = Buffer.concat(chunks).toString("utf8").trim();
|
|
||||||
if (!txt) return resolve({});
|
|
||||||
try { resolve(JSON.parse(txt)); } catch { resolve({}); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------ packers / parsers (little-endian wire format) ------
|
|
||||||
|
|
||||||
// 24-byte sSndHeader
|
|
||||||
function packSndHeader({ nTxnCd, nOpCd = 0, nNumRecsSent = 0, nNumRecsRqrd = 0, nTxnId = 0, cBtMake = 0 }) {
|
|
||||||
const b = Buffer.alloc(24, 0);
|
|
||||||
let o = 0;
|
|
||||||
b.writeInt32LE(nTxnCd, o); o += 4;
|
|
||||||
b.writeInt32LE(nOpCd, o); o += 4;
|
|
||||||
b.writeInt32LE(nNumRecsSent, o); o += 4;
|
|
||||||
b.writeInt32LE(nNumRecsRqrd, o); o += 4;
|
|
||||||
b.writeInt32LE(nTxnId, o); o += 4;
|
|
||||||
b.writeUInt8(cBtMake, o); // +3 padding remain 0
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse 18/24-byte sRcvHeader
|
|
||||||
function parseRcvHeaderFlexible(buf) {
|
|
||||||
if (buf.length < 18) throw new Error(`Reply too short for RcvHeader: ${buf.length} bytes`);
|
|
||||||
let o = 0;
|
|
||||||
const nTxnCd = buf.readInt32LE(o); o += 4;
|
|
||||||
const nRetCd = buf.readInt32LE(o); o += 4;
|
|
||||||
const nNumRecs = buf.readInt32LE(o); o += 4;
|
|
||||||
const nTxnId = buf.readInt32LE(o); o += 4;
|
|
||||||
const cBtMake = buf.readUInt8(o);
|
|
||||||
return { nTxnCd, nRetCd, nNumRecs, nTxnId, cBtMake, size: buf.length >= 24 ? 24 : 18 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// build a normalized 18-byte header (for display only)
|
|
||||||
function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = 0 }) {
|
|
||||||
const b = Buffer.alloc(18, 0);
|
|
||||||
let o = 0;
|
|
||||||
b.writeInt32LE(nTxnCd, o); o += 4;
|
|
||||||
b.writeInt32LE(nRetCd, o); o += 4;
|
|
||||||
b.writeInt32LE(nNumRecs, o); o += 4;
|
|
||||||
b.writeInt32LE(nTxnId, o); o += 4;
|
|
||||||
b.writeUInt8(cBtMake, o);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------ 512-byte packetization (MFC style) ------
|
|
||||||
|
|
||||||
function absSend(sock, payload) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let off = 0;
|
|
||||||
function writeNext() {
|
|
||||||
const remaining = payload.length - off;
|
|
||||||
const toCopy = Math.min(PAYLOAD_PER_PKT, Math.max(remaining, 0));
|
|
||||||
const pkt = Buffer.alloc(PKT_SIZE, 0);
|
|
||||||
pkt[0] = (off + toCopy >= payload.length) ? 48 /* '0' */ : 49 /* '1' */;
|
|
||||||
if (toCopy > 0) payload.copy(pkt, 1, off, off + toCopy);
|
|
||||||
off += toCopy;
|
|
||||||
sock.write(pkt, (err) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
if (pkt[0] === 48) return resolve(); // last
|
|
||||||
writeNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
writeNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function absRecv(sock, timeoutMs = 5000) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks = [];
|
|
||||||
let buf = Buffer.alloc(0);
|
|
||||||
const timer = timeoutMs ? setTimeout(() => done(new Error("TCP timeout")), timeoutMs) : null;
|
|
||||||
|
|
||||||
function done(err) {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
sock.off("data", onData);
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(Buffer.concat(chunks));
|
|
||||||
}
|
|
||||||
|
|
||||||
function onData(data) {
|
|
||||||
buf = Buffer.concat([buf, data]);
|
|
||||||
while (buf.length >= PKT_SIZE) {
|
|
||||||
const pkt = buf.subarray(0, PKT_SIZE);
|
|
||||||
buf = buf.subarray(PKT_SIZE);
|
|
||||||
const flag = pkt[0];
|
|
||||||
chunks.push(pkt.subarray(1));
|
|
||||||
if (flag === 48) return done(); // '0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sock.on("data", onData);
|
|
||||||
sock.on("error", (e) => done(e));
|
|
||||||
sock.on("end", () => done(new Error("Socket ended before final '0' packet")));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function absRoundtrip(payload, timeoutMs = 7000) {
|
|
||||||
const sock = new net.Socket();
|
|
||||||
await new Promise((res, rej) => sock.connect(ABS_PORT, ABS_HOST, res).once("error", rej));
|
|
||||||
try {
|
|
||||||
await absSend(sock, payload);
|
|
||||||
const replyConcat = await absRecv(sock, timeoutMs);
|
|
||||||
sock.end();
|
|
||||||
return replyConcat;
|
|
||||||
} catch (e) {
|
|
||||||
try { sock.destroy(); } catch {}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------ routes ------
|
|
||||||
|
|
||||||
app.get("/", (_req, res) => res.send("ABS bridge is up"));
|
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
|
||||||
const s = new net.Socket();
|
|
||||||
s.setTimeout(1500);
|
|
||||||
s.connect(ABS_PORT, ABS_HOST, () => { s.destroy(); res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}` }); });
|
|
||||||
s.on("timeout", () => { s.destroy(); res.status(504).json({ ok: false, error: "TCP timeout" }); });
|
|
||||||
s.on("error", (e) => { s.destroy(); res.status(502).json({ ok: false, error: String(e) }); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /abs/poll -> send SndHeader(nTxnCd=ABS_POLL) and return normalized + raw headers
|
|
||||||
app.post("/abs/poll", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const body = await readJsonBody(req); // optional
|
|
||||||
const btMake = (typeof body.btMake === "string")
|
|
||||||
? body.btMake.charCodeAt(0)
|
|
||||||
: (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00);
|
|
||||||
|
|
||||||
const header = packSndHeader({
|
|
||||||
nTxnCd: ABS_POLL,
|
|
||||||
nOpCd: 0,
|
|
||||||
nNumRecsSent: 0,
|
|
||||||
nNumRecsRqrd: 0,
|
|
||||||
nTxnId: 0, // for poll, server ignores; keep 0
|
|
||||||
cBtMake: btMake
|
|
||||||
});
|
|
||||||
|
|
||||||
const reply = await absRoundtrip(header, 7000);
|
|
||||||
|
|
||||||
// parse raw reply header (18 or 24)
|
|
||||||
const headerSlice = reply.subarray(0, Math.min(reply.length, 24));
|
|
||||||
const parsed = parseRcvHeaderFlexible(headerSlice);
|
|
||||||
|
|
||||||
// success = nRetCd == SUCCESS (server may zero nTxnCd for poll)
|
|
||||||
const success = parsed.nRetCd === SUCCESS;
|
|
||||||
|
|
||||||
// normalized view: always show ABS_POLL in JSON so Postman sees a “proper” header
|
|
||||||
const normalized = {
|
|
||||||
nTxnCd: parsed.nTxnCd || ABS_POLL,
|
|
||||||
nRetCd: parsed.nRetCd,
|
|
||||||
nNumRecs: parsed.nNumRecs,
|
|
||||||
nTxnId: parsed.nTxnId,
|
|
||||||
cBtMake: parsed.cBtMake,
|
|
||||||
size: 18
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
ok: true,
|
|
||||||
target: `${ABS_HOST}:${ABS_PORT}`,
|
|
||||||
sentHeaderHex: header.toString("hex"),
|
|
||||||
replyBytes: reply.length,
|
|
||||||
replyHexFirst64: reply.subarray(0, 64).toString("hex"),
|
|
||||||
parsedRcvHeaderRaw: parsed, // actual header from server
|
|
||||||
normalizedRcvHeader: normalized, // “proper” poll header for display
|
|
||||||
normalizedHeaderHex: buildRcvHeader18(normalized).toString("hex"),
|
|
||||||
success
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
res.status(502).json({ ok: false, error: String(e) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------ start HTTP server ------
|
|
||||||
const HTTP_PORT = Number(process.env.HTTP_BRIDGE_PORT || 8080);
|
|
||||||
app.listen(HTTP_PORT, () => {
|
|
||||||
console.log(`ABS HTTP bridge listening on :${HTTP_PORT}`);
|
|
||||||
console.log(`Target ABS server: ${ABS_HOST}:${ABS_PORT}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
764
btinfo.js
Normal file
764
btinfo.js
Normal file
@ -0,0 +1,764 @@
|
|||||||
|
// server.js — robust HTTP→TCP bridge for ABS (ABS_POLL, LOG, BTI, RPI)
|
||||||
|
// Node 18+ (CommonJS)
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const net = require("net");
|
||||||
|
|
||||||
|
const ABS_HOST = process.env.ABS_HOST || "192.0.0.14";
|
||||||
|
const ABS_PORT = Number(process.env.ABS_PORT || 7000);
|
||||||
|
|
||||||
|
const ABS_POLL = 178;
|
||||||
|
const LOG = 100;
|
||||||
|
const LOGBT = 6013;
|
||||||
|
const BTI = 6014;
|
||||||
|
const RPI = 6015;
|
||||||
|
const SUCCESS = 0;
|
||||||
|
|
||||||
|
const PKT_SIZE = 512;
|
||||||
|
const PAYLOAD_PER_PKT = PKT_SIZE - 1;
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = Number(process.env.BRIDGE_TIMEOUT_MS || 20000);
|
||||||
|
const RETRIES = Number(process.env.BRIDGE_RETRIES || 2);
|
||||||
|
const RETRY_DELAY_MS = Number(process.env.BRIDGE_RETRY_DELAY_MS || 500);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
function readJsonBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on("data", (c) => chunks.push(c));
|
||||||
|
req.on("end", () => {
|
||||||
|
if (!chunks.length) return resolve({});
|
||||||
|
const txt = Buffer.concat(chunks).toString("utf8").trim();
|
||||||
|
if (!txt) return resolve({});
|
||||||
|
try { resolve(JSON.parse(txt)); } catch { resolve({}); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
function writeFixedAscii(buf, offset, s, len) {
|
||||||
|
const str = (s ?? "").toString();
|
||||||
|
for (let i = 0; i < len; i++) buf[offset + i] = i < str.length ? (str.charCodeAt(i) & 0xff) : 0x00;
|
||||||
|
return offset + len;
|
||||||
|
}
|
||||||
|
function readFixedAscii(buf, offset, len) {
|
||||||
|
let end = offset;
|
||||||
|
const max = offset + len;
|
||||||
|
while (end < max && buf[end] !== 0) end++;
|
||||||
|
const raw = buf.subarray(offset, end).toString("ascii");
|
||||||
|
return { value: raw.trimEnd(), next: offset + len };
|
||||||
|
}
|
||||||
|
function hexSlice(buf, off, len) {
|
||||||
|
const s = Math.max(0, off);
|
||||||
|
const e = Math.min(buf.length, off + len);
|
||||||
|
return buf.subarray(s, e).toString("hex");
|
||||||
|
}
|
||||||
|
function remaining(buf, off){ return Math.max(0, buf.length - off); }
|
||||||
|
|
||||||
|
// ---------- packetization ----------
|
||||||
|
function absSend(sock, payload) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let off = 0;
|
||||||
|
function sendNext() {
|
||||||
|
const remaining = payload.length - off;
|
||||||
|
const toCopy = Math.min(PAYLOAD_PER_PKT, Math.max(remaining, 0));
|
||||||
|
const pkt = Buffer.alloc(PKT_SIZE, 0);
|
||||||
|
pkt[0] = (off + toCopy >= payload.length) ? 48 : 49;
|
||||||
|
if (toCopy > 0) payload.copy(pkt, 1, off, off + toCopy);
|
||||||
|
off += toCopy;
|
||||||
|
sock.write(pkt, (err) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (pkt[0] === 48) return resolve();
|
||||||
|
sendNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!payload || payload.length === 0) {
|
||||||
|
const pkt = Buffer.alloc(PKT_SIZE, 0);
|
||||||
|
pkt[0] = 48;
|
||||||
|
sock.write(pkt, (err) => err ? reject(err) : resolve());
|
||||||
|
} else {
|
||||||
|
sendNext();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function absRecv(sock, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
const timer = timeoutMs ? setTimeout(() => done(new Error("TCP timeout")), timeoutMs) : null;
|
||||||
|
|
||||||
|
function done(err) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
sock.off("data", onData);
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(Buffer.concat(chunks));
|
||||||
|
}
|
||||||
|
function onData(data) {
|
||||||
|
buf = Buffer.concat([buf, data]);
|
||||||
|
while (buf.length >= PKT_SIZE) {
|
||||||
|
const pkt = buf.subarray(0, PKT_SIZE);
|
||||||
|
buf = buf.subarray(PKT_SIZE);
|
||||||
|
const flag = pkt[0];
|
||||||
|
chunks.push(pkt.subarray(1));
|
||||||
|
if (flag === 48) return done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.on("data", onData);
|
||||||
|
sock.on("error", (e) => done(e));
|
||||||
|
sock.on("end", () => done(new Error("Socket ended before final '0' packet")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function absRoundtrip(payload, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
||||||
|
const sock = new net.Socket();
|
||||||
|
await new Promise((res, rej) => sock.connect(ABS_PORT, ABS_HOST, res).once("error", rej));
|
||||||
|
try {
|
||||||
|
await absSend(sock, payload || Buffer.alloc(0));
|
||||||
|
const replyConcat = await absRecv(sock, timeoutMs);
|
||||||
|
sock.end();
|
||||||
|
return replyConcat;
|
||||||
|
} catch (e) {
|
||||||
|
try { sock.destroy(); } catch {}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function withRetry(fn, tries = RETRIES, delayMs = RETRY_DELAY_MS) {
|
||||||
|
let lastErr;
|
||||||
|
for (let i=0;i<tries;i++){
|
||||||
|
try { return await fn(); }
|
||||||
|
catch (e){ lastErr = e; if (i+1<tries) await new Promise(r=>setTimeout(r, delayMs)); }
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- MFC-style password "encryption" ----------
|
||||||
|
function encryptPasswordLikeMFC(plain) {
|
||||||
|
const sec = new Date().getUTCSeconds();
|
||||||
|
const key = Number(String(sec)[0] || "0");
|
||||||
|
let out = "";
|
||||||
|
const s = (plain ?? "").toString();
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const ch = s[i];
|
||||||
|
const d = (ch >= "0" && ch <= "9") ? (ch.charCodeAt(0) - 48) : 0;
|
||||||
|
out += String((d + key) % 10);
|
||||||
|
}
|
||||||
|
return { enc: out + String(key), key, utcSeconds: sec };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- headers ----------
|
||||||
|
function packSndHeader({ nTxnCd, nOpCd = 0, nNumRecsSent = 0, nNumRecsRqrd = 0, nTxnId = 0, cBtMake = 0 }) {
|
||||||
|
const b = Buffer.alloc(24, 0);
|
||||||
|
let o = 0;
|
||||||
|
b.writeInt32LE(nTxnCd, o); o += 4;
|
||||||
|
b.writeInt32LE(nOpCd, o); o += 4;
|
||||||
|
b.writeInt32LE(nNumRecsSent, o); o += 4;
|
||||||
|
b.writeInt32LE(nNumRecsRqrd, o); o += 4;
|
||||||
|
b.writeInt32LE(nTxnId, o); o += 4;
|
||||||
|
b.writeUInt8(cBtMake & 0xff, o);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smart header parser: test 18 & 24, choose plausible. */
|
||||||
|
function parseRcvHeaderFlexible(buf) {
|
||||||
|
if (buf.length < 18) throw new Error(`Reply too short for RcvHeader: ${buf.length} bytes`);
|
||||||
|
function readAs(len) {
|
||||||
|
let o = 0;
|
||||||
|
const nTxnCd = buf.readInt32LE(o); o += 4;
|
||||||
|
const nRetCd = buf.readInt32LE(o); o += 4;
|
||||||
|
const nNumRecs = buf.readInt32LE(o); o += 4;
|
||||||
|
const nTxnId = buf.readInt32LE(o); o += 4;
|
||||||
|
const cBtMake = buf.readUInt8(o);
|
||||||
|
return { nTxnCd, nRetCd, nNumRecs, nTxnId, cBtMake, size: len };
|
||||||
|
}
|
||||||
|
const h18 = readAs(18);
|
||||||
|
const ok18 = (h18.nTxnCd >= 50 && h18.nTxnCd < 10000) && (h18.nRetCd >= 0 && h18.nRetCd < 10000);
|
||||||
|
let h24=null, ok24=false;
|
||||||
|
if (buf.length >= 24) {
|
||||||
|
h24 = readAs(24);
|
||||||
|
ok24 = (h24.nTxnCd >= 50 && h24.nTxnCd < 10000) && (h24.nRetCd >= 0 && h24.nRetCd < 10000);
|
||||||
|
}
|
||||||
|
if (ok18 && !ok24) return h18;
|
||||||
|
if (!ok18 && ok24) return h24;
|
||||||
|
if (ok18 && ok24) return h18; // prefer 18 to avoid over-advancing
|
||||||
|
return h18;
|
||||||
|
}
|
||||||
|
function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = 0 }) {
|
||||||
|
const b = Buffer.alloc(18, 0);
|
||||||
|
let o = 0;
|
||||||
|
b.writeInt32LE(nTxnCd, o); o += 4;
|
||||||
|
b.writeInt32LE(nRetCd, o); o += 4;
|
||||||
|
b.writeInt32LE(nNumRecs, o); o += 4;
|
||||||
|
b.writeInt32LE(nTxnId, o); o += 4;
|
||||||
|
b.writeUInt8(cBtMake & 0xff, o);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- aligners / detectors ----------
|
||||||
|
function looksLikeHeaderAt(buf, off){
|
||||||
|
if (remaining(buf, off) < 18) return false;
|
||||||
|
try {
|
||||||
|
const nTxnCd = buf.readInt32LE(off);
|
||||||
|
return (nTxnCd >= 50 && nTxnCd < 10000);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
/** align to plausible header; try +0 / +4 */
|
||||||
|
function alignToPossibleHeader(reply, off) {
|
||||||
|
if (looksLikeHeaderAt(reply, off)) return off;
|
||||||
|
if (remaining(reply, off) >= 22 && looksLikeHeaderAt(reply, off + 4)) return off + 4;
|
||||||
|
return off;
|
||||||
|
}
|
||||||
|
/** align to where ASCII year "20" begins for login body */
|
||||||
|
function alignToAsciiYear(reply, off) {
|
||||||
|
const tryOffsets = [0, 2, 4, -4];
|
||||||
|
for (const d of tryOffsets) {
|
||||||
|
const o = off + d;
|
||||||
|
if (o >= 0 && o + 1 < reply.length) {
|
||||||
|
const b0 = reply[o], b1 = reply[o+1];
|
||||||
|
if (b0 === 0x32 && b1 === 0x30) return o; // '2''0'
|
||||||
|
if (reply[o] === 0x2f && o >= 4 && reply[o-4] === 0x32 && reply[o-3] === 0x30) return o-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return off;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustForOptionalOp(replyBuf, bodyOffset) {
|
||||||
|
if (replyBuf.length >= bodyOffset + 4) {
|
||||||
|
const maybeOp = replyBuf.readInt32LE(bodyOffset);
|
||||||
|
if (maybeOp === LOGBT || maybeOp === LOG || maybeOp === 0 || (maybeOp >= 100 && maybeOp <= 10000)) {
|
||||||
|
return { offset: bodyOffset + 4, extraOp: maybeOp };
|
||||||
|
} else {
|
||||||
|
const looksLikeYear = replyBuf[bodyOffset] === 0x32 && replyBuf[bodyOffset + 1] === 0x30;
|
||||||
|
const looksLikeYearFwd = replyBuf[bodyOffset + 4] === 0x32 && replyBuf[bodyOffset + 5] === 0x30;
|
||||||
|
if (!looksLikeYear && looksLikeYearFwd) return { offset: bodyOffset + 4, extraOp: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bodyOffset >= 4 && replyBuf[bodyOffset] === 0x2f) {
|
||||||
|
const couldBeYear = replyBuf[bodyOffset - 4] === 0x32 && replyBuf[bodyOffset - 3] === 0x30;
|
||||||
|
if (couldBeYear) return { offset: bodyOffset - 4, extraOp: null };
|
||||||
|
}
|
||||||
|
return { offset: bodyOffset, extraOp: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeAdvanceOrBreak(buf, off, want, outObj){
|
||||||
|
if (remaining(buf, off) < want){
|
||||||
|
outObj._truncated = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- parsers ----------
|
||||||
|
function packSndLog({ usrId = "", opCard, passwd, btId }) {
|
||||||
|
const b = Buffer.alloc(38, 0);
|
||||||
|
let o = 0;
|
||||||
|
o = writeFixedAscii(b, o, usrId, 5);
|
||||||
|
o = writeFixedAscii(b, o, opCard, 17);
|
||||||
|
o = writeFixedAscii(b, o, passwd, 11);
|
||||||
|
o = writeFixedAscii(b, o, btId, 5);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
function parseRcvLog(buf, offset = 0) {
|
||||||
|
let o = offset;
|
||||||
|
const t1 = readFixedAscii(buf, o, 20); const cDateTime = t1.value; o = t1.next;
|
||||||
|
const t2 = readFixedAscii(buf, o, 31); const cUsrNm = t2.value; o = t2.next;
|
||||||
|
const t3 = readFixedAscii(buf, o, 5 ); const cUsrId = t3.value; o = t3.next;
|
||||||
|
const t4 = readFixedAscii(buf, o, 31); const cSupNm = t4.value; o = t4.next;
|
||||||
|
const t5 = readFixedAscii(buf, o, 5 ); const cSupId = t5.value; o = t5.next;
|
||||||
|
const t6 = readFixedAscii(buf, o, 5 ); const cUsrTyp = t6.value; o = t6.next;
|
||||||
|
|
||||||
|
function rf() { const v = buf.readFloatLE(o); o += 4; return v; }
|
||||||
|
function ri() { const v = buf.readInt32LE(o); o += 4; return v; }
|
||||||
|
|
||||||
|
const fOpenBal = rf();
|
||||||
|
const fTktSalesByVoucher = rf();
|
||||||
|
const fTktSalesByCash = rf();
|
||||||
|
const fTktSalesByMemCard = rf();
|
||||||
|
const nTktSalesByVoucherCount = ri();
|
||||||
|
const nTktSalesByCashCount = ri();
|
||||||
|
const nTktSalesByMemCardCount = ri();
|
||||||
|
const fPayoutByVoucher = rf();
|
||||||
|
const fPayoutByCash = rf();
|
||||||
|
const fPayoutByMemCard = rf();
|
||||||
|
const nPayoutByVoucherCount = ri();
|
||||||
|
const nPayoutByCashCount = ri();
|
||||||
|
const nPayoutByMemCardCount = ri();
|
||||||
|
const fCancelByVoucher = rf();
|
||||||
|
const fCancelByCash = rf();
|
||||||
|
const fCancelByMemCard = rf();
|
||||||
|
const nCancelByVoucherCount = ri();
|
||||||
|
const nCancelByCashCount = ri();
|
||||||
|
const nCancelByMemCardCount = ri();
|
||||||
|
const fDeposit = rf();
|
||||||
|
const fWithdrawAmt = rf();
|
||||||
|
const fVoucherSales = rf();
|
||||||
|
const fVoucherEncash = rf();
|
||||||
|
const fCloseBal = rf();
|
||||||
|
const fSaleTarget = rf();
|
||||||
|
|
||||||
|
return {
|
||||||
|
cDateTime, cUsrNm, cUsrId, cSupNm, cSupId, cUsrTyp,
|
||||||
|
fOpenBal, fTktSalesByVoucher, fTktSalesByCash, fTktSalesByMemCard,
|
||||||
|
nTktSalesByVoucherCount, nTktSalesByCashCount, nTktSalesByMemCardCount,
|
||||||
|
fPayoutByVoucher, fPayoutByCash, fPayoutByMemCard,
|
||||||
|
nPayoutByVoucherCount, nPayoutByCashCount, nPayoutByMemCardCount,
|
||||||
|
fCancelByVoucher, fCancelByCash, fCancelByMemCard,
|
||||||
|
nCancelByVoucherCount, nCancelByCashCount, nCancelByMemCardCount,
|
||||||
|
fDeposit, fWithdrawAmt, fVoucherSales, fVoucherEncash,
|
||||||
|
fCloseBal, fSaleTarget,
|
||||||
|
size: o - offset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// function parseRcvBtiMst(buf, offset) {
|
||||||
|
// let o = offset;
|
||||||
|
|
||||||
|
// const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next;
|
||||||
|
// const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next;
|
||||||
|
// const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next;
|
||||||
|
// const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next;
|
||||||
|
|
||||||
|
// const cBtMode = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
|
||||||
|
// const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next;
|
||||||
|
|
||||||
|
// // Debug: raw hex of these fields
|
||||||
|
// console.error("fMinSaleBet raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
// const fMinSaleBet = buf.readInt32LE(o); o += 4;
|
||||||
|
|
||||||
|
// console.error("fMaxSaleBet raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
// const fMaxSaleBet = buf.readInt32LE(o); o += 4;
|
||||||
|
|
||||||
|
// console.error("fPayMaxAmt raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
// const fPayMaxAmt = buf.readInt32LE(o); o += 4;
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// cBtId, cBtName, cEnclCd, cGrpCd,
|
||||||
|
// cBtMode, cMoneyTyp,
|
||||||
|
// fMinSaleBet, fMaxSaleBet, fPayMaxAmt,
|
||||||
|
// size: o - offset
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
function parseRcvBtiMst(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
|
||||||
|
const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next;
|
||||||
|
const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next;
|
||||||
|
const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next;
|
||||||
|
const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next;
|
||||||
|
|
||||||
|
const cBtMode = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
|
||||||
|
const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next;
|
||||||
|
|
||||||
|
// align to 4-byte boundary (for float fields)
|
||||||
|
o = (o + 3) & ~3;
|
||||||
|
|
||||||
|
console.error("fMinSaleBet raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
const fMinSaleBet = buf.readFloatLE(o); o += 4;
|
||||||
|
|
||||||
|
console.error("fMaxSaleBet raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
const fMaxSaleBet = buf.readFloatLE(o); o += 4;
|
||||||
|
|
||||||
|
console.error("fPayMaxAmt raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
const fPayMaxAmt = buf.readFloatLE(o); o += 4;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cBtId, cBtName, cEnclCd, cGrpCd,
|
||||||
|
cBtMode, cMoneyTyp,
|
||||||
|
fMinSaleBet, fMaxSaleBet, fPayMaxAmt,
|
||||||
|
size: o - offset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function parseRcvBtiDtl(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const cDtlTyp = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
|
||||||
|
// If not enough bytes to try the happy path, take what's available
|
||||||
|
if (remaining(buf, o) < 4) {
|
||||||
|
const avail = Math.max(0, remaining(buf, o));
|
||||||
|
const aRem = readFixedAscii(buf, o, avail);
|
||||||
|
const cPoolOrVenue = aRem.value;
|
||||||
|
o = aRem.next;
|
||||||
|
const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : "";
|
||||||
|
if (o < buf.length) o += 1;
|
||||||
|
return { cDtlTyp, cPoolOrVenue, cDtlSts, size: o - offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try 3-byte pool/venue first (most common)
|
||||||
|
const a3 = readFixedAscii(buf, o, 3);
|
||||||
|
const cPoolOrVenue3 = a3.value;
|
||||||
|
const after3 = a3.next;
|
||||||
|
|
||||||
|
// Candidate status byte (must be printable ASCII — typical statuses are letters/digits)
|
||||||
|
const statusCandidate = buf[after3];
|
||||||
|
const isPrintable = (statusCandidate >= 0x20 && statusCandidate <= 0x7E);
|
||||||
|
|
||||||
|
if (isPrintable) {
|
||||||
|
o = after3;
|
||||||
|
const cDtlSts = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
return { cDtlTyp, cPoolOrVenue: cPoolOrVenue3, cDtlSts, size: o - offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: read 4-byte pool/venue (older/odd layouts)
|
||||||
|
const a4 = readFixedAscii(buf, o, 4);
|
||||||
|
const cPoolOrVenue4 = a4.value;
|
||||||
|
o = a4.next;
|
||||||
|
const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : "";
|
||||||
|
if (o < buf.length) o += 1;
|
||||||
|
return { cDtlTyp, cPoolOrVenue: cPoolOrVenue4, cDtlSts, size: o - offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function parseRcvRpi1(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const nRaceDt = buf.readInt32LE(o); o += 4;
|
||||||
|
const cVenueNum = buf.readUInt8(o); o += 1;
|
||||||
|
const cVenueSts = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
const aR = readFixedAscii(buf, o, 15); const cRaces = aR.value; o = aR.next;
|
||||||
|
const aAd = readFixedAscii(buf, o, 41); const cAdvertisement = aAd.value; o = aAd.next;
|
||||||
|
return { nRaceDt, cVenueNum, cVenueSts, cRaces, cAdvertisement, size: o - offset };
|
||||||
|
}
|
||||||
|
function parseRcvRpi2(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const nRaceDt = buf.readInt32LE(o); o += 4;
|
||||||
|
const cVenueNum = buf.readUInt8(o); o += 1;
|
||||||
|
const cRaceNum = buf.readUInt8(o); o += 1;
|
||||||
|
const aH = readFixedAscii(buf, o, 25); const cHorses = aH.value; o = aH.next;
|
||||||
|
const nRaceStartTm = buf.readInt32LE(o); o += 4;
|
||||||
|
return { nRaceDt, cVenueNum, cRaceNum, cHorses, nRaceStartTm, size: o - offset };
|
||||||
|
}
|
||||||
|
function parseRcvRpi3(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const nRaceDt = buf.readInt32LE(o); o += 4;
|
||||||
|
const cVenueNum = buf.readUInt8(o); o += 1;
|
||||||
|
const cRaceNum = buf.readUInt8(o); o += 1;
|
||||||
|
const aP = readFixedAscii(buf, o, 9); const cPools = aP.value; o = aP.next;
|
||||||
|
return { nRaceDt, cVenueNum, cRaceNum, cPools, size: o - offset };
|
||||||
|
}
|
||||||
|
function parseRcvRpi4(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const nRaceDt = buf.readInt32LE(o); o += 4;
|
||||||
|
const cVenueNum = buf.readUInt8(o); o += 1;
|
||||||
|
const cRaceNum = buf.subarray(o, o + 3).toString("ascii").replace(/\0/g, ""); o += 3;
|
||||||
|
const aSel = readFixedAscii(buf, o, 11); const cSelectRaceNum = aSel.value; o = aSel.next;
|
||||||
|
const cPoolSts = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
return { nRaceDt, cVenueNum, cRaceNum, cSelectRaceNum, cPoolSts, size: o - offset };
|
||||||
|
}
|
||||||
|
function parseRcvPvm(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const a1 = readFixedAscii(buf, o, 4); const cRaceVenue = a1.value; o = a1.next;
|
||||||
|
const a2 = readFixedAscii(buf, o, 4); const cPoolId = a2.value; o = a2.next;
|
||||||
|
const fUnitPrice = buf.readFloatLE(o); o += 4;
|
||||||
|
const fTaxRate = buf.readFloatLE(o); o += 4;
|
||||||
|
const fDeductRate = buf.readFloatLE(o); o += 4;
|
||||||
|
const a3 = readFixedAscii(buf, o, 31); const cRemarks = a3.value; o = a3.next;
|
||||||
|
return { cRaceVenue, cPoolId, fUnitPrice, fTaxRate, fDeductRate, cRemarks, size: o - offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- routes ----------
|
||||||
|
app.get("/", (_req, res) => res.send("ABS bridge is up"));
|
||||||
|
app.get("/health", (_req, res) => {
|
||||||
|
const s = new net.Socket();
|
||||||
|
s.setTimeout(1500);
|
||||||
|
s.connect(ABS_PORT, ABS_HOST, () => { s.destroy(); res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}` }); });
|
||||||
|
s.on("timeout", () => { s.destroy(); res.status(504).json({ ok: false, error: "TCP timeout" }); });
|
||||||
|
s.on("error", (e) => { s.destroy(); res.status(502).json({ ok: false, error: String(e) }); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- ABS_POLL ----------
|
||||||
|
app.post("/abs/poll", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const btMake = (typeof body.btMake === "string")
|
||||||
|
? body.btMake.charCodeAt(0)
|
||||||
|
: (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00);
|
||||||
|
|
||||||
|
const header = packSndHeader({ nTxnCd: ABS_POLL, nOpCd: 0, nNumRecsSent: 0, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: btMake });
|
||||||
|
|
||||||
|
const reply = await withRetry(() => absRoundtrip(header, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
|
||||||
|
const hdr = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24)));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
target: `${ABS_HOST}:${ABS_PORT}`,
|
||||||
|
sentHeaderHex: header.toString("hex"),
|
||||||
|
replyBytes: reply.length,
|
||||||
|
replyHexFirst64: reply.subarray(0, 64).toString("hex"),
|
||||||
|
parsedRcvHeaderRaw: hdr,
|
||||||
|
normalizedRcvHeader: { ...hdr, size: 18 },
|
||||||
|
normalizedHeaderHex: buildRcvHeader18(hdr).toString("hex"),
|
||||||
|
success: hdr.nRetCd === SUCCESS
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ ok: false, error: String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- LOGIN ----------
|
||||||
|
app.post("/login", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const opCard = (body.opCard ?? "").toString();
|
||||||
|
const btId = (body.btId ?? "").toString();
|
||||||
|
const usrId = (body.usrId ?? "").toString();
|
||||||
|
const plain = (body.password ?? "");
|
||||||
|
const givenEnc = (body.passwordEnc ?? "");
|
||||||
|
|
||||||
|
if (!opCard || !btId || (!plain && !givenEnc)) {
|
||||||
|
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let encInfo = null;
|
||||||
|
let passwd = givenEnc ? String(givenEnc) : (encInfo = encryptPasswordLikeMFC(String(plain))).enc;
|
||||||
|
|
||||||
|
const btMake = (typeof body.btMake === "string")
|
||||||
|
? body.btMake.charCodeAt(0)
|
||||||
|
: (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00);
|
||||||
|
|
||||||
|
const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: LOGBT, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: btMake });
|
||||||
|
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
|
||||||
|
const sendBuf = Buffer.concat([sndHeader, sndLog]);
|
||||||
|
|
||||||
|
const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
|
||||||
|
|
||||||
|
const rcvHeader = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24)));
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
ok: true,
|
||||||
|
target: `${ABS_HOST}:${ABS_PORT}`,
|
||||||
|
sentHeaderHex: sndHeader.toString("hex"),
|
||||||
|
sentBodyHex: sndLog.toString("hex"),
|
||||||
|
replyBytes: reply.length,
|
||||||
|
replyHexFirst64: reply.subarray(0, 64).toString("hex"),
|
||||||
|
rcvHeaderRaw: rcvHeader,
|
||||||
|
success: rcvHeader.nRetCd === SUCCESS
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!givenEnc && encInfo) {
|
||||||
|
json.encryption = { used: true, utcSeconds: encInfo.utcSeconds, key: encInfo.key, encPasswd: passwd };
|
||||||
|
} else if (givenEnc) {
|
||||||
|
json.encryption = { used: false, providedPasswordEnc: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// locate login body start
|
||||||
|
let bodyOffset = rcvHeader.size;
|
||||||
|
bodyOffset = adjustForOptionalOp(reply, bodyOffset).offset;
|
||||||
|
bodyOffset = alignToAsciiYear(reply, bodyOffset);
|
||||||
|
|
||||||
|
json.offsetProbe = {
|
||||||
|
bodyOffset,
|
||||||
|
around: reply.subarray(Math.max(0, bodyOffset - 8), Math.min(reply.length, bodyOffset + 48)).toString("ascii")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (json.success && reply.length >= bodyOffset + 20) {
|
||||||
|
try { json.log = parseRcvLog(reply, bodyOffset); } catch (e) { json.parseLogError = String(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(json);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ ok: false, error: String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- DOWNLOAD BT INFO (LOG/BTI) ----------
|
||||||
|
app.post("/download/btinfo", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const opCard = (body.opCard ?? "").toString();
|
||||||
|
const btId = (body.btId ?? "").toString();
|
||||||
|
const usrId = (body.usrId ?? "").toString();
|
||||||
|
const plain = (body.password ?? "");
|
||||||
|
const encPass = (body.passwordEnc ?? "");
|
||||||
|
if (!opCard || !btId || (!plain && !encPass)) {
|
||||||
|
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
|
||||||
|
}
|
||||||
|
const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc;
|
||||||
|
|
||||||
|
const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: BTI, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: 0 });
|
||||||
|
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
|
||||||
|
const sendBuf = Buffer.concat([sndHeader, sndLog]);
|
||||||
|
|
||||||
|
const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
|
||||||
|
|
||||||
|
const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0,64).toString('hex') };
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
if (remaining(reply, offset) < 18) return res.json({ ok:false, error:"truncated: no header" });
|
||||||
|
// const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24)));
|
||||||
|
// result.primaryHeader = hdr;
|
||||||
|
// offset += hdr.size;
|
||||||
|
|
||||||
|
const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24)));
|
||||||
|
result.primaryHeader = hdr;
|
||||||
|
|
||||||
|
// Advance to end of header *and* align to the natural 4-byte boundary
|
||||||
|
// (C structs are commonly padded so sizeof(sRcvHeader) will often be 20, not 18)
|
||||||
|
const hdrLenAligned = (hdr.size + 3) & ~3; // e.g. 18 -> 20, 24 -> 24
|
||||||
|
// safety: ensure we have at least hdrLenAligned bytes in reply
|
||||||
|
if (!safeAdvanceOrBreak(reply, 0, hdrLenAligned, result)) {
|
||||||
|
result.error = "truncated: incomplete header (after alignment)";
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
offset = hdrLenAligned;
|
||||||
|
|
||||||
|
|
||||||
|
if (hdr.nRetCd !== SUCCESS) {
|
||||||
|
result.error = `server returned nRetCd=${hdr.nRetCd}`;
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!safeAdvanceOrBreak(reply, offset, 5+31+5+4+1+4+4+4+4, result)) {
|
||||||
|
result.error = "truncated before BTI master";
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
const btiMst = parseRcvBtiMst(reply, offset);
|
||||||
|
offset += btiMst.size;
|
||||||
|
result.btiMst = btiMst;
|
||||||
|
|
||||||
|
const details = [];
|
||||||
|
let readCount = 0;
|
||||||
|
while (readCount < hdr.nNumRecs) {
|
||||||
|
if (looksLikeHeaderAt(reply, offset)) break;
|
||||||
|
// if (!safeAdvanceOrBreak(reply, offset, 1+4+1, result)) break;
|
||||||
|
if (!safeAdvanceOrBreak(reply, offset, 1+3+1, result)) break;
|
||||||
|
|
||||||
|
const dt = parseRcvBtiDtl(reply, offset);
|
||||||
|
offset += dt.size;
|
||||||
|
details.push(dt);
|
||||||
|
readCount++;
|
||||||
|
}
|
||||||
|
result.btiDetails = details;
|
||||||
|
result.btiReadCount = readCount;
|
||||||
|
result.parsedBytes = offset;
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ ok: false, error: String(e), stack: e.stack });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- DOWNLOAD RP INFO (LOG/RPI) ----------
|
||||||
|
app.post("/download/rpinfo", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const debug = String((req.query.debug ?? "")).toLowerCase() === "1";
|
||||||
|
const opCard = (body.opCard ?? "").toString();
|
||||||
|
const btId = (body.btId ?? "").toString();
|
||||||
|
const usrId = (body.usrId ?? "").toString();
|
||||||
|
const plain = (body.password ?? "");
|
||||||
|
const encPass = (body.passwordEnc ?? "");
|
||||||
|
if (!opCard || !btId || (!plain && !encPass)) {
|
||||||
|
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
|
||||||
|
}
|
||||||
|
const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc;
|
||||||
|
|
||||||
|
const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: RPI, nNumRecsSent: 1, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: 0 });
|
||||||
|
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
|
||||||
|
const sendBuf = Buffer.concat([sndHeader, sndLog]);
|
||||||
|
|
||||||
|
const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
|
||||||
|
|
||||||
|
const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0,64).toString('hex') };
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
if (remaining(reply, offset) < 18) return res.json({ ok:false, error:"truncated: no top header" });
|
||||||
|
const topHdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset+24)));
|
||||||
|
offset += topHdr.size;
|
||||||
|
result.topHeader = topHdr;
|
||||||
|
if (topHdr.nRetCd !== SUCCESS) {
|
||||||
|
result.error = `server returned nRetCd=${topHdr.nRetCd}`;
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
if (debug) result.debug_afterTopHdr = hexSlice(reply, 0, 128);
|
||||||
|
|
||||||
|
// Helper to parse a section safely
|
||||||
|
function parseSection(minRecBytes, recParser, outArrName, outHeaderName) {
|
||||||
|
if (remaining(reply, offset) < 18) { result._truncated = true; return { header:null, items:[], read:0 }; }
|
||||||
|
offset = alignToPossibleHeader(reply, offset);
|
||||||
|
const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset+24)));
|
||||||
|
offset += hdr.size;
|
||||||
|
const adj = adjustForOptionalOp(reply, offset);
|
||||||
|
offset = adj.offset;
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
let read = 0;
|
||||||
|
while (read < hdr.nNumRecs) {
|
||||||
|
if (looksLikeHeaderAt(reply, offset)) break;
|
||||||
|
if (!safeAdvanceOrBreak(reply, offset, minRecBytes, result)) break;
|
||||||
|
const rec = recParser(reply, offset);
|
||||||
|
offset += rec.size;
|
||||||
|
items.push(rec);
|
||||||
|
read++;
|
||||||
|
}
|
||||||
|
result[outHeaderName] = hdr;
|
||||||
|
result[outArrName] = items;
|
||||||
|
result[outArrName.replace(/s?$/, "ReadCount")] = read;
|
||||||
|
return { header: hdr, items, read };
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPI1 .. RPI4 .. PVM
|
||||||
|
parseSection(4+1+1+15+41, parseRcvRpi1, "rpi1", "rpi1Header");
|
||||||
|
if (debug) result.debug_afterRpi1 = hexSlice(reply, Math.max(0, offset-64), 128);
|
||||||
|
|
||||||
|
parseSection(4+1+1+25+4, parseRcvRpi2, "rpi2", "rpi2Header");
|
||||||
|
if (debug) result.debug_afterRpi2 = hexSlice(reply, Math.max(0, offset-64), 128);
|
||||||
|
|
||||||
|
parseSection(4+1+1+9, parseRcvRpi3, "rpi3", "rpi3Header");
|
||||||
|
if (debug) result.debug_afterRpi3 = hexSlice(reply, Math.max(0, offset-64), 128);
|
||||||
|
|
||||||
|
parseSection(4+1+3+11+1, parseRcvRpi4, "rpi4", "rpi4Header");
|
||||||
|
if (debug) result.debug_afterRpi4 = hexSlice(reply, Math.max(0, offset-64), 128);
|
||||||
|
|
||||||
|
parseSection(4+4+4+4+4+31, parseRcvPvm, "pvms", "pvmHeader");
|
||||||
|
|
||||||
|
result.parsedBytes = offset;
|
||||||
|
|
||||||
|
// Build summary
|
||||||
|
const raceCard = {};
|
||||||
|
for (const v of result.rpi1 || []) {
|
||||||
|
const key = `${v.nRaceDt}_${v.cVenueNum}`;
|
||||||
|
raceCard[key] = { date: v.nRaceDt, venueNum: v.cVenueNum, status: v.cVenueSts, races: {}, advertisement: v.cAdvertisement };
|
||||||
|
}
|
||||||
|
for (const r of result.rpi2 || []) {
|
||||||
|
const key = `${r.nRaceDt}_${r.cVenueNum}`;
|
||||||
|
const raceKey = `${r.cRaceNum}`;
|
||||||
|
if (!raceCard[key]) raceCard[key] = { date: r.nRaceDt, venueNum: r.cVenueNum, races: {} };
|
||||||
|
raceCard[key].races[raceKey] = { raceNum: r.cRaceNum, horses: (r.cHorses || "").split(/\s+/).filter(Boolean), startTime: r.nRaceStartTm, pools: [] };
|
||||||
|
}
|
||||||
|
for (const p of result.rpi3 || []) {
|
||||||
|
const key = `${p.nRaceDt}_${p.cVenueNum}`;
|
||||||
|
const raceKey = `${p.cRaceNum}`;
|
||||||
|
if (raceCard[key] && raceCard[key].races[raceKey]) raceCard[key].races[raceKey].pools = (p.cPools || "").split("").filter(Boolean);
|
||||||
|
}
|
||||||
|
for (const s of result.rpi4 || []) {
|
||||||
|
const key = `${s.nRaceDt}_${s.cVenueNum}`;
|
||||||
|
const raceKey = `${s.cRaceNum}`;
|
||||||
|
if (raceCard[key] && raceCard[key].races[raceKey]) {
|
||||||
|
raceCard[key].races[raceKey].poolStatus = s.cPoolSts;
|
||||||
|
raceCard[key].races[raceKey].selectRaceNum = s.cSelectRaceNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.raceCard = raceCard;
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ ok: false, error: String(e), stack: e.stack });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- start HTTP server ----
|
||||||
|
const HTTP_PORT = Number(process.env.HTTP_BRIDGE_PORT || 8080);
|
||||||
|
app.listen(HTTP_PORT, () => {
|
||||||
|
console.log(`ABS HTTP bridge listening on :${HTTP_PORT}`);
|
||||||
|
console.log(`Target ABS server: ${ABS_HOST}:${ABS_PORT}`);
|
||||||
|
});
|
||||||
4
btinfo.sh
Executable file
4
btinfo.sh
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
curl -sS -X POST "http://localhost:8080/download/btinfo" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"opCard":"021804111066","password":"0660000","btId":"0483","usrId":"","btMake":0}' | jq
|
||||||
6
login.sh
Executable file
6
login.sh
Executable file
@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
curl -sS -X POST "http://localhost:8080/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"opCard":"021804111066","password":"0660000","btId":"0483","usrId":"","btMake":0}' | jq
|
||||||
|
|
||||||
114
login_logs.json
114
login_logs.json
@ -1,114 +0,0 @@
|
|||||||
{
|
|
||||||
"info": {
|
|
||||||
"_postman_id": "3dd79a7d-47b7-4f2a-86b9-e35f48b54bb3",
|
|
||||||
"name": "login_abs",
|
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
|
||||||
"_exporter_id": "46024117",
|
|
||||||
"_collection_link": "https://johnlogan-6539403.postman.co/workspace/john-logan's-Workspace~cde4aa19-228c-45b3-88cb-692cf4289226/collection/46024117-3dd79a7d-47b7-4f2a-86b9-e35f48b54bb3?action=share&source=collection_link&creator=46024117"
|
|
||||||
},
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "http://localhost:8080/login",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"opCard\": \"021804111066\",\n \"password\": \"0660000\",\n \"btId\": \"0483\",\n \"usrId\": \"\",\n \"btMake\": \"btmake\"\n}\n",
|
|
||||||
"options": {
|
|
||||||
"raw": {
|
|
||||||
"language": "json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "http://localhost:8080/login",
|
|
||||||
"protocol": "http",
|
|
||||||
"host": [
|
|
||||||
"localhost"
|
|
||||||
],
|
|
||||||
"port": "8080",
|
|
||||||
"path": [
|
|
||||||
"login"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": [
|
|
||||||
{
|
|
||||||
"name": "http://localhost:8080/login",
|
|
||||||
"originalRequest": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"opCard\": \"021804111066\",\n \"password\": \"0660000\",\n \"btId\": \"0483\",\n \"usrId\": \"\",\n \"btMake\": \"btmake\"\n}\n",
|
|
||||||
"options": {
|
|
||||||
"raw": {
|
|
||||||
"language": "json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "http://localhost:8080/login",
|
|
||||||
"protocol": "http",
|
|
||||||
"host": [
|
|
||||||
"localhost"
|
|
||||||
],
|
|
||||||
"port": "8080",
|
|
||||||
"path": [
|
|
||||||
"login"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"status": "OK",
|
|
||||||
"code": 200,
|
|
||||||
"_postman_previewlanguage": "",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "X-Powered-By",
|
|
||||||
"value": "Express"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json; charset=utf-8"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "Content-Length",
|
|
||||||
"value": "1189"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "ETag",
|
|
||||||
"value": "W/\"4a5-+Kk5viaxNtYc9xJLAnpb48uH9QQ\""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "Date",
|
|
||||||
"value": "Wed, 20 Aug 2025 11:07:59 GMT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "Connection",
|
|
||||||
"value": "keep-alive"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "Keep-Alive",
|
|
||||||
"value": "timeout=5"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cookie": [],
|
|
||||||
"body": "{\n \"ok\": true,\n \"target\": \"192.0.0.14:7000\",\n \"sentHeaderHex\": \"640000007d17000001000000010000000000000062000000\",\n \"sentBodyHex\": \"0000000000303231383034313131303636000000000035313135353535350000003034383300\",\n \"replyBytes\": 511,\n \"replyHexFirst64\": \"6400000000000000020000000000000062000000323032352f30382f32302f31363a33373a33350052454e554b41505241534144202e4b000000000000000000\",\n \"rcvHeaderRaw\": {\n \"nTxnCd\": 100,\n \"nRetCd\": 0,\n \"nNumRecs\": 2,\n \"nTxnId\": 0,\n \"cBtMake\": 98,\n \"size\": 24\n },\n \"success\": true,\n \"encryption\": {\n \"used\": true,\n \"utcSeconds\": 59,\n \"key\": 5,\n \"encPasswd\": \"51155555\"\n },\n \"log\": {\n \"cDateTime\": \"/08/20/16:37:35\",\n \"cUsrNm\": \"KAPRASAD .K\",\n \"cUsrId\": \"\",\n \"cSupNm\": \"\",\n \"cSupId\": \"\",\n \"cUsrTyp\": \"\",\n \"fOpenBal\": 0,\n \"fTktSalesByVoucher\": 0,\n \"fTktSalesByCash\": 0,\n \"fTktSalesByMemCard\": 0,\n \"nTktSalesByVoucherCount\": 0,\n \"nTktSalesByCashCount\": 0,\n \"nTktSalesByMemCardCount\": 0,\n \"fPayoutByVoucher\": 0,\n \"fPayoutByCash\": 0,\n \"fPayoutByMemCard\": 0,\n \"nPayoutByVoucherCount\": 0,\n \"nPayoutByCashCount\": 0,\n \"nPayoutByMemCardCount\": 0,\n \"fCancelByVoucher\": 0,\n \"fCancelByCash\": 0,\n \"fCancelByMemCard\": 0,\n \"nCancelByVoucherCount\": 0,\n \"nCancelByCashCount\": 0,\n \"nCancelByMemCardCount\": 0,\n \"fDeposit\": 0,\n \"fWithdrawAmt\": 0,\n \"fVoucherSales\": 0,\n \"fVoucherEncash\": 0,\n \"fCloseBal\": 0,\n \"fSaleTarget\": 0,\n \"size\": 197\n }\n}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
764
mathew.js
Normal file
764
mathew.js
Normal file
@ -0,0 +1,764 @@
|
|||||||
|
// server.js — robust HTTP→TCP bridge for ABS (ABS_POLL, LOG, BTI, RPI)
|
||||||
|
// Node 18+ (CommonJS)
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const net = require("net");
|
||||||
|
|
||||||
|
const ABS_HOST = process.env.ABS_HOST || "192.0.0.14";
|
||||||
|
const ABS_PORT = Number(process.env.ABS_PORT || 7000);
|
||||||
|
|
||||||
|
const ABS_POLL = 178;
|
||||||
|
const LOG = 100;
|
||||||
|
const LOGBT = 6013;
|
||||||
|
const BTI = 6014;
|
||||||
|
const RPI = 6015;
|
||||||
|
const SUCCESS = 0;
|
||||||
|
|
||||||
|
const PKT_SIZE = 512;
|
||||||
|
const PAYLOAD_PER_PKT = PKT_SIZE - 1;
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = Number(process.env.BRIDGE_TIMEOUT_MS || 20000);
|
||||||
|
const RETRIES = Number(process.env.BRIDGE_RETRIES || 2);
|
||||||
|
const RETRY_DELAY_MS = Number(process.env.BRIDGE_RETRY_DELAY_MS || 500);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
function readJsonBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on("data", (c) => chunks.push(c));
|
||||||
|
req.on("end", () => {
|
||||||
|
if (!chunks.length) return resolve({});
|
||||||
|
const txt = Buffer.concat(chunks).toString("utf8").trim();
|
||||||
|
if (!txt) return resolve({});
|
||||||
|
try { resolve(JSON.parse(txt)); } catch { resolve({}); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
function writeFixedAscii(buf, offset, s, len) {
|
||||||
|
const str = (s ?? "").toString();
|
||||||
|
for (let i = 0; i < len; i++) buf[offset + i] = i < str.length ? (str.charCodeAt(i) & 0xff) : 0x00;
|
||||||
|
return offset + len;
|
||||||
|
}
|
||||||
|
function readFixedAscii(buf, offset, len) {
|
||||||
|
let end = offset;
|
||||||
|
const max = offset + len;
|
||||||
|
while (end < max && buf[end] !== 0) end++;
|
||||||
|
const raw = buf.subarray(offset, end).toString("ascii");
|
||||||
|
return { value: raw.trimEnd(), next: offset + len };
|
||||||
|
}
|
||||||
|
function hexSlice(buf, off, len) {
|
||||||
|
const s = Math.max(0, off);
|
||||||
|
const e = Math.min(buf.length, off + len);
|
||||||
|
return buf.subarray(s, e).toString("hex");
|
||||||
|
}
|
||||||
|
function remaining(buf, off){ return Math.max(0, buf.length - off); }
|
||||||
|
|
||||||
|
// ---------- packetization ----------
|
||||||
|
function absSend(sock, payload) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let off = 0;
|
||||||
|
function sendNext() {
|
||||||
|
const remaining = payload.length - off;
|
||||||
|
const toCopy = Math.min(PAYLOAD_PER_PKT, Math.max(remaining, 0));
|
||||||
|
const pkt = Buffer.alloc(PKT_SIZE, 0);
|
||||||
|
pkt[0] = (off + toCopy >= payload.length) ? 48 : 49;
|
||||||
|
if (toCopy > 0) payload.copy(pkt, 1, off, off + toCopy);
|
||||||
|
off += toCopy;
|
||||||
|
sock.write(pkt, (err) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (pkt[0] === 48) return resolve();
|
||||||
|
sendNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!payload || payload.length === 0) {
|
||||||
|
const pkt = Buffer.alloc(PKT_SIZE, 0);
|
||||||
|
pkt[0] = 48;
|
||||||
|
sock.write(pkt, (err) => err ? reject(err) : resolve());
|
||||||
|
} else {
|
||||||
|
sendNext();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function absRecv(sock, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
const timer = timeoutMs ? setTimeout(() => done(new Error("TCP timeout")), timeoutMs) : null;
|
||||||
|
|
||||||
|
function done(err) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
sock.off("data", onData);
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(Buffer.concat(chunks));
|
||||||
|
}
|
||||||
|
function onData(data) {
|
||||||
|
buf = Buffer.concat([buf, data]);
|
||||||
|
while (buf.length >= PKT_SIZE) {
|
||||||
|
const pkt = buf.subarray(0, PKT_SIZE);
|
||||||
|
buf = buf.subarray(PKT_SIZE);
|
||||||
|
const flag = pkt[0];
|
||||||
|
chunks.push(pkt.subarray(1));
|
||||||
|
if (flag === 48) return done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.on("data", onData);
|
||||||
|
sock.on("error", (e) => done(e));
|
||||||
|
sock.on("end", () => done(new Error("Socket ended before final '0' packet")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function absRoundtrip(payload, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
||||||
|
const sock = new net.Socket();
|
||||||
|
await new Promise((res, rej) => sock.connect(ABS_PORT, ABS_HOST, res).once("error", rej));
|
||||||
|
try {
|
||||||
|
await absSend(sock, payload || Buffer.alloc(0));
|
||||||
|
const replyConcat = await absRecv(sock, timeoutMs);
|
||||||
|
sock.end();
|
||||||
|
return replyConcat;
|
||||||
|
} catch (e) {
|
||||||
|
try { sock.destroy(); } catch {}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function withRetry(fn, tries = RETRIES, delayMs = RETRY_DELAY_MS) {
|
||||||
|
let lastErr;
|
||||||
|
for (let i=0;i<tries;i++){
|
||||||
|
try { return await fn(); }
|
||||||
|
catch (e){ lastErr = e; if (i+1<tries) await new Promise(r=>setTimeout(r, delayMs)); }
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- MFC-style password "encryption" ----------
|
||||||
|
function encryptPasswordLikeMFC(plain) {
|
||||||
|
const sec = new Date().getUTCSeconds();
|
||||||
|
const key = Number(String(sec)[0] || "0");
|
||||||
|
let out = "";
|
||||||
|
const s = (plain ?? "").toString();
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const ch = s[i];
|
||||||
|
const d = (ch >= "0" && ch <= "9") ? (ch.charCodeAt(0) - 48) : 0;
|
||||||
|
out += String((d + key) % 10);
|
||||||
|
}
|
||||||
|
return { enc: out + String(key), key, utcSeconds: sec };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- headers ----------
|
||||||
|
function packSndHeader({ nTxnCd, nOpCd = 0, nNumRecsSent = 0, nNumRecsRqrd = 0, nTxnId = 0, cBtMake = 0 }) {
|
||||||
|
const b = Buffer.alloc(24, 0);
|
||||||
|
let o = 0;
|
||||||
|
b.writeInt32LE(nTxnCd, o); o += 4;
|
||||||
|
b.writeInt32LE(nOpCd, o); o += 4;
|
||||||
|
b.writeInt32LE(nNumRecsSent, o); o += 4;
|
||||||
|
b.writeInt32LE(nNumRecsRqrd, o); o += 4;
|
||||||
|
b.writeInt32LE(nTxnId, o); o += 4;
|
||||||
|
b.writeUInt8(cBtMake & 0xff, o);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smart header parser: test 18 & 24, choose plausible. */
|
||||||
|
function parseRcvHeaderFlexible(buf) {
|
||||||
|
if (buf.length < 18) throw new Error(`Reply too short for RcvHeader: ${buf.length} bytes`);
|
||||||
|
function readAs(len) {
|
||||||
|
let o = 0;
|
||||||
|
const nTxnCd = buf.readInt32LE(o); o += 4;
|
||||||
|
const nRetCd = buf.readInt32LE(o); o += 4;
|
||||||
|
const nNumRecs = buf.readInt32LE(o); o += 4;
|
||||||
|
const nTxnId = buf.readInt32LE(o); o += 4;
|
||||||
|
const cBtMake = buf.readUInt8(o);
|
||||||
|
return { nTxnCd, nRetCd, nNumRecs, nTxnId, cBtMake, size: len };
|
||||||
|
}
|
||||||
|
const h18 = readAs(18);
|
||||||
|
const ok18 = (h18.nTxnCd >= 50 && h18.nTxnCd < 10000) && (h18.nRetCd >= 0 && h18.nRetCd < 10000);
|
||||||
|
let h24=null, ok24=false;
|
||||||
|
if (buf.length >= 24) {
|
||||||
|
h24 = readAs(24);
|
||||||
|
ok24 = (h24.nTxnCd >= 50 && h24.nTxnCd < 10000) && (h24.nRetCd >= 0 && h24.nRetCd < 10000);
|
||||||
|
}
|
||||||
|
if (ok18 && !ok24) return h18;
|
||||||
|
if (!ok18 && ok24) return h24;
|
||||||
|
if (ok18 && ok24) return h18; // prefer 18 to avoid over-advancing
|
||||||
|
return h18;
|
||||||
|
}
|
||||||
|
function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = 0 }) {
|
||||||
|
const b = Buffer.alloc(18, 0);
|
||||||
|
let o = 0;
|
||||||
|
b.writeInt32LE(nTxnCd, o); o += 4;
|
||||||
|
b.writeInt32LE(nRetCd, o); o += 4;
|
||||||
|
b.writeInt32LE(nNumRecs, o); o += 4;
|
||||||
|
b.writeInt32LE(nTxnId, o); o += 4;
|
||||||
|
b.writeUInt8(cBtMake & 0xff, o);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- aligners / detectors ----------
|
||||||
|
function looksLikeHeaderAt(buf, off){
|
||||||
|
if (remaining(buf, off) < 18) return false;
|
||||||
|
try {
|
||||||
|
const nTxnCd = buf.readInt32LE(off);
|
||||||
|
return (nTxnCd >= 50 && nTxnCd < 10000);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
/** align to plausible header; try +0 / +4 */
|
||||||
|
function alignToPossibleHeader(reply, off) {
|
||||||
|
if (looksLikeHeaderAt(reply, off)) return off;
|
||||||
|
if (remaining(reply, off) >= 22 && looksLikeHeaderAt(reply, off + 4)) return off + 4;
|
||||||
|
return off;
|
||||||
|
}
|
||||||
|
/** align to where ASCII year "20" begins for login body */
|
||||||
|
function alignToAsciiYear(reply, off) {
|
||||||
|
const tryOffsets = [0, 2, 4, -4];
|
||||||
|
for (const d of tryOffsets) {
|
||||||
|
const o = off + d;
|
||||||
|
if (o >= 0 && o + 1 < reply.length) {
|
||||||
|
const b0 = reply[o], b1 = reply[o+1];
|
||||||
|
if (b0 === 0x32 && b1 === 0x30) return o; // '2''0'
|
||||||
|
if (reply[o] === 0x2f && o >= 4 && reply[o-4] === 0x32 && reply[o-3] === 0x30) return o-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return off;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustForOptionalOp(replyBuf, bodyOffset) {
|
||||||
|
if (replyBuf.length >= bodyOffset + 4) {
|
||||||
|
const maybeOp = replyBuf.readInt32LE(bodyOffset);
|
||||||
|
if (maybeOp === LOGBT || maybeOp === LOG || maybeOp === 0 || (maybeOp >= 100 && maybeOp <= 10000)) {
|
||||||
|
return { offset: bodyOffset + 4, extraOp: maybeOp };
|
||||||
|
} else {
|
||||||
|
const looksLikeYear = replyBuf[bodyOffset] === 0x32 && replyBuf[bodyOffset + 1] === 0x30;
|
||||||
|
const looksLikeYearFwd = replyBuf[bodyOffset + 4] === 0x32 && replyBuf[bodyOffset + 5] === 0x30;
|
||||||
|
if (!looksLikeYear && looksLikeYearFwd) return { offset: bodyOffset + 4, extraOp: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bodyOffset >= 4 && replyBuf[bodyOffset] === 0x2f) {
|
||||||
|
const couldBeYear = replyBuf[bodyOffset - 4] === 0x32 && replyBuf[bodyOffset - 3] === 0x30;
|
||||||
|
if (couldBeYear) return { offset: bodyOffset - 4, extraOp: null };
|
||||||
|
}
|
||||||
|
return { offset: bodyOffset, extraOp: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeAdvanceOrBreak(buf, off, want, outObj){
|
||||||
|
if (remaining(buf, off) < want){
|
||||||
|
outObj._truncated = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- parsers ----------
|
||||||
|
function packSndLog({ usrId = "", opCard, passwd, btId }) {
|
||||||
|
const b = Buffer.alloc(38, 0);
|
||||||
|
let o = 0;
|
||||||
|
o = writeFixedAscii(b, o, usrId, 5);
|
||||||
|
o = writeFixedAscii(b, o, opCard, 17);
|
||||||
|
o = writeFixedAscii(b, o, passwd, 11);
|
||||||
|
o = writeFixedAscii(b, o, btId, 5);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
function parseRcvLog(buf, offset = 0) {
|
||||||
|
let o = offset;
|
||||||
|
const t1 = readFixedAscii(buf, o, 20); const cDateTime = t1.value; o = t1.next;
|
||||||
|
const t2 = readFixedAscii(buf, o, 31); const cUsrNm = t2.value; o = t2.next;
|
||||||
|
const t3 = readFixedAscii(buf, o, 5 ); const cUsrId = t3.value; o = t3.next;
|
||||||
|
const t4 = readFixedAscii(buf, o, 31); const cSupNm = t4.value; o = t4.next;
|
||||||
|
const t5 = readFixedAscii(buf, o, 5 ); const cSupId = t5.value; o = t5.next;
|
||||||
|
const t6 = readFixedAscii(buf, o, 5 ); const cUsrTyp = t6.value; o = t6.next;
|
||||||
|
|
||||||
|
function rf() { const v = buf.readFloatLE(o); o += 4; return v; }
|
||||||
|
function ri() { const v = buf.readInt32LE(o); o += 4; return v; }
|
||||||
|
|
||||||
|
const fOpenBal = rf();
|
||||||
|
const fTktSalesByVoucher = rf();
|
||||||
|
const fTktSalesByCash = rf();
|
||||||
|
const fTktSalesByMemCard = rf();
|
||||||
|
const nTktSalesByVoucherCount = ri();
|
||||||
|
const nTktSalesByCashCount = ri();
|
||||||
|
const nTktSalesByMemCardCount = ri();
|
||||||
|
const fPayoutByVoucher = rf();
|
||||||
|
const fPayoutByCash = rf();
|
||||||
|
const fPayoutByMemCard = rf();
|
||||||
|
const nPayoutByVoucherCount = ri();
|
||||||
|
const nPayoutByCashCount = ri();
|
||||||
|
const nPayoutByMemCardCount = ri();
|
||||||
|
const fCancelByVoucher = rf();
|
||||||
|
const fCancelByCash = rf();
|
||||||
|
const fCancelByMemCard = rf();
|
||||||
|
const nCancelByVoucherCount = ri();
|
||||||
|
const nCancelByCashCount = ri();
|
||||||
|
const nCancelByMemCardCount = ri();
|
||||||
|
const fDeposit = rf();
|
||||||
|
const fWithdrawAmt = rf();
|
||||||
|
const fVoucherSales = rf();
|
||||||
|
const fVoucherEncash = rf();
|
||||||
|
const fCloseBal = rf();
|
||||||
|
const fSaleTarget = rf();
|
||||||
|
|
||||||
|
return {
|
||||||
|
cDateTime, cUsrNm, cUsrId, cSupNm, cSupId, cUsrTyp,
|
||||||
|
fOpenBal, fTktSalesByVoucher, fTktSalesByCash, fTktSalesByMemCard,
|
||||||
|
nTktSalesByVoucherCount, nTktSalesByCashCount, nTktSalesByMemCardCount,
|
||||||
|
fPayoutByVoucher, fPayoutByCash, fPayoutByMemCard,
|
||||||
|
nPayoutByVoucherCount, nPayoutByCashCount, nPayoutByMemCardCount,
|
||||||
|
fCancelByVoucher, fCancelByCash, fCancelByMemCard,
|
||||||
|
nCancelByVoucherCount, nCancelByCashCount, nCancelByMemCardCount,
|
||||||
|
fDeposit, fWithdrawAmt, fVoucherSales, fVoucherEncash,
|
||||||
|
fCloseBal, fSaleTarget,
|
||||||
|
size: o - offset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// function parseRcvBtiMst(buf, offset) {
|
||||||
|
// let o = offset;
|
||||||
|
|
||||||
|
// const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next;
|
||||||
|
// const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next;
|
||||||
|
// const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next;
|
||||||
|
// const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next;
|
||||||
|
|
||||||
|
// const cBtMode = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
|
||||||
|
// const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next;
|
||||||
|
|
||||||
|
// // Debug: raw hex of these fields
|
||||||
|
// console.error("fMinSaleBet raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
// const fMinSaleBet = buf.readInt32LE(o); o += 4;
|
||||||
|
|
||||||
|
// console.error("fMaxSaleBet raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
// const fMaxSaleBet = buf.readInt32LE(o); o += 4;
|
||||||
|
|
||||||
|
// console.error("fPayMaxAmt raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
// const fPayMaxAmt = buf.readInt32LE(o); o += 4;
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// cBtId, cBtName, cEnclCd, cGrpCd,
|
||||||
|
// cBtMode, cMoneyTyp,
|
||||||
|
// fMinSaleBet, fMaxSaleBet, fPayMaxAmt,
|
||||||
|
// size: o - offset
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
function parseRcvBtiMst(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
|
||||||
|
const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next;
|
||||||
|
const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next;
|
||||||
|
const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next;
|
||||||
|
const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next;
|
||||||
|
|
||||||
|
const cBtMode = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
|
||||||
|
const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next;
|
||||||
|
|
||||||
|
// align to 4-byte boundary (for float fields)
|
||||||
|
o = (o + 3) & ~3;
|
||||||
|
|
||||||
|
console.error("fMinSaleBet raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
const fMinSaleBet = buf.readFloatLE(o); o += 4;
|
||||||
|
|
||||||
|
console.error("fMaxSaleBet raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
const fMaxSaleBet = buf.readFloatLE(o); o += 4;
|
||||||
|
|
||||||
|
console.error("fPayMaxAmt raw:", buf.subarray(o, o+4).toString("hex"));
|
||||||
|
const fPayMaxAmt = buf.readFloatLE(o); o += 4;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cBtId, cBtName, cEnclCd, cGrpCd,
|
||||||
|
cBtMode, cMoneyTyp,
|
||||||
|
fMinSaleBet, fMaxSaleBet, fPayMaxAmt,
|
||||||
|
size: o - offset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function parseRcvBtiDtl(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const cDtlTyp = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
|
||||||
|
// If not enough bytes to try the happy path, take what's available
|
||||||
|
if (remaining(buf, o) < 4) {
|
||||||
|
const avail = Math.max(0, remaining(buf, o));
|
||||||
|
const aRem = readFixedAscii(buf, o, avail);
|
||||||
|
const cPoolOrVenue = aRem.value;
|
||||||
|
o = aRem.next;
|
||||||
|
const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : "";
|
||||||
|
if (o < buf.length) o += 1;
|
||||||
|
return { cDtlTyp, cPoolOrVenue, cDtlSts, size: o - offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try 3-byte pool/venue first (most common)
|
||||||
|
const a3 = readFixedAscii(buf, o, 3);
|
||||||
|
const cPoolOrVenue3 = a3.value;
|
||||||
|
const after3 = a3.next;
|
||||||
|
|
||||||
|
// Candidate status byte (must be printable ASCII — typical statuses are letters/digits)
|
||||||
|
const statusCandidate = buf[after3];
|
||||||
|
const isPrintable = (statusCandidate >= 0x20 && statusCandidate <= 0x7E);
|
||||||
|
|
||||||
|
if (isPrintable) {
|
||||||
|
o = after3;
|
||||||
|
const cDtlSts = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
return { cDtlTyp, cPoolOrVenue: cPoolOrVenue3, cDtlSts, size: o - offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: read 4-byte pool/venue (older/odd layouts)
|
||||||
|
const a4 = readFixedAscii(buf, o, 4);
|
||||||
|
const cPoolOrVenue4 = a4.value;
|
||||||
|
o = a4.next;
|
||||||
|
const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : "";
|
||||||
|
if (o < buf.length) o += 1;
|
||||||
|
return { cDtlTyp, cPoolOrVenue: cPoolOrVenue4, cDtlSts, size: o - offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function parseRcvRpi1(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const nRaceDt = buf.readInt32LE(o); o += 4;
|
||||||
|
const cVenueNum = buf.readUInt8(o); o += 1;
|
||||||
|
const cVenueSts = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
const aR = readFixedAscii(buf, o, 15); const cRaces = aR.value; o = aR.next;
|
||||||
|
const aAd = readFixedAscii(buf, o, 41); const cAdvertisement = aAd.value; o = aAd.next;
|
||||||
|
return { nRaceDt, cVenueNum, cVenueSts, cRaces, cAdvertisement, size: o - offset };
|
||||||
|
}
|
||||||
|
function parseRcvRpi2(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const nRaceDt = buf.readInt32LE(o); o += 4;
|
||||||
|
const cVenueNum = buf.readUInt8(o); o += 1;
|
||||||
|
const cRaceNum = buf.readUInt8(o); o += 1;
|
||||||
|
const aH = readFixedAscii(buf, o, 25); const cHorses = aH.value; o = aH.next;
|
||||||
|
const nRaceStartTm = buf.readInt32LE(o); o += 4;
|
||||||
|
return { nRaceDt, cVenueNum, cRaceNum, cHorses, nRaceStartTm, size: o - offset };
|
||||||
|
}
|
||||||
|
function parseRcvRpi3(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const nRaceDt = buf.readInt32LE(o); o += 4;
|
||||||
|
const cVenueNum = buf.readUInt8(o); o += 1;
|
||||||
|
const cRaceNum = buf.readUInt8(o); o += 1;
|
||||||
|
const aP = readFixedAscii(buf, o, 9); const cPools = aP.value; o = aP.next;
|
||||||
|
return { nRaceDt, cVenueNum, cRaceNum, cPools, size: o - offset };
|
||||||
|
}
|
||||||
|
function parseRcvRpi4(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const nRaceDt = buf.readInt32LE(o); o += 4;
|
||||||
|
const cVenueNum = buf.readUInt8(o); o += 1;
|
||||||
|
const cRaceNum = buf.subarray(o, o + 3).toString("ascii").replace(/\0/g, ""); o += 3;
|
||||||
|
const aSel = readFixedAscii(buf, o, 11); const cSelectRaceNum = aSel.value; o = aSel.next;
|
||||||
|
const cPoolSts = String.fromCharCode(buf[o]); o += 1;
|
||||||
|
return { nRaceDt, cVenueNum, cRaceNum, cSelectRaceNum, cPoolSts, size: o - offset };
|
||||||
|
}
|
||||||
|
function parseRcvPvm(buf, offset) {
|
||||||
|
let o = offset;
|
||||||
|
const a1 = readFixedAscii(buf, o, 4); const cRaceVenue = a1.value; o = a1.next;
|
||||||
|
const a2 = readFixedAscii(buf, o, 4); const cPoolId = a2.value; o = a2.next;
|
||||||
|
const fUnitPrice = buf.readFloatLE(o); o += 4;
|
||||||
|
const fTaxRate = buf.readFloatLE(o); o += 4;
|
||||||
|
const fDeductRate = buf.readFloatLE(o); o += 4;
|
||||||
|
const a3 = readFixedAscii(buf, o, 31); const cRemarks = a3.value; o = a3.next;
|
||||||
|
return { cRaceVenue, cPoolId, fUnitPrice, fTaxRate, fDeductRate, cRemarks, size: o - offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- routes ----------
|
||||||
|
app.get("/", (_req, res) => res.send("ABS bridge is up"));
|
||||||
|
app.get("/health", (_req, res) => {
|
||||||
|
const s = new net.Socket();
|
||||||
|
s.setTimeout(1500);
|
||||||
|
s.connect(ABS_PORT, ABS_HOST, () => { s.destroy(); res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}` }); });
|
||||||
|
s.on("timeout", () => { s.destroy(); res.status(504).json({ ok: false, error: "TCP timeout" }); });
|
||||||
|
s.on("error", (e) => { s.destroy(); res.status(502).json({ ok: false, error: String(e) }); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- ABS_POLL ----------
|
||||||
|
app.post("/abs/poll", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const btMake = (typeof body.btMake === "string")
|
||||||
|
? body.btMake.charCodeAt(0)
|
||||||
|
: (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00);
|
||||||
|
|
||||||
|
const header = packSndHeader({ nTxnCd: ABS_POLL, nOpCd: 0, nNumRecsSent: 0, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: btMake });
|
||||||
|
|
||||||
|
const reply = await withRetry(() => absRoundtrip(header, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
|
||||||
|
const hdr = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24)));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
target: `${ABS_HOST}:${ABS_PORT}`,
|
||||||
|
sentHeaderHex: header.toString("hex"),
|
||||||
|
replyBytes: reply.length,
|
||||||
|
replyHexFirst64: reply.subarray(0, 64).toString("hex"),
|
||||||
|
parsedRcvHeaderRaw: hdr,
|
||||||
|
normalizedRcvHeader: { ...hdr, size: 18 },
|
||||||
|
normalizedHeaderHex: buildRcvHeader18(hdr).toString("hex"),
|
||||||
|
success: hdr.nRetCd === SUCCESS
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ ok: false, error: String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- LOGIN ----------
|
||||||
|
app.post("/login", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const opCard = (body.opCard ?? "").toString();
|
||||||
|
const btId = (body.btId ?? "").toString();
|
||||||
|
const usrId = (body.usrId ?? "").toString();
|
||||||
|
const plain = (body.password ?? "");
|
||||||
|
const givenEnc = (body.passwordEnc ?? "");
|
||||||
|
|
||||||
|
if (!opCard || !btId || (!plain && !givenEnc)) {
|
||||||
|
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let encInfo = null;
|
||||||
|
let passwd = givenEnc ? String(givenEnc) : (encInfo = encryptPasswordLikeMFC(String(plain))).enc;
|
||||||
|
|
||||||
|
const btMake = (typeof body.btMake === "string")
|
||||||
|
? body.btMake.charCodeAt(0)
|
||||||
|
: (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00);
|
||||||
|
|
||||||
|
const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: LOGBT, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: btMake });
|
||||||
|
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
|
||||||
|
const sendBuf = Buffer.concat([sndHeader, sndLog]);
|
||||||
|
|
||||||
|
const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
|
||||||
|
|
||||||
|
const rcvHeader = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24)));
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
ok: true,
|
||||||
|
target: `${ABS_HOST}:${ABS_PORT}`,
|
||||||
|
sentHeaderHex: sndHeader.toString("hex"),
|
||||||
|
sentBodyHex: sndLog.toString("hex"),
|
||||||
|
replyBytes: reply.length,
|
||||||
|
replyHexFirst64: reply.subarray(0, 64).toString("hex"),
|
||||||
|
rcvHeaderRaw: rcvHeader,
|
||||||
|
success: rcvHeader.nRetCd === SUCCESS
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!givenEnc && encInfo) {
|
||||||
|
json.encryption = { used: true, utcSeconds: encInfo.utcSeconds, key: encInfo.key, encPasswd: passwd };
|
||||||
|
} else if (givenEnc) {
|
||||||
|
json.encryption = { used: false, providedPasswordEnc: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// locate login body start
|
||||||
|
let bodyOffset = rcvHeader.size;
|
||||||
|
bodyOffset = adjustForOptionalOp(reply, bodyOffset).offset;
|
||||||
|
bodyOffset = alignToAsciiYear(reply, bodyOffset);
|
||||||
|
|
||||||
|
json.offsetProbe = {
|
||||||
|
bodyOffset,
|
||||||
|
around: reply.subarray(Math.max(0, bodyOffset - 8), Math.min(reply.length, bodyOffset + 48)).toString("ascii")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (json.success && reply.length >= bodyOffset + 20) {
|
||||||
|
try { json.log = parseRcvLog(reply, bodyOffset); } catch (e) { json.parseLogError = String(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(json);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ ok: false, error: String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- DOWNLOAD BT INFO (LOG/BTI) ----------
|
||||||
|
app.post("/download/btinfo", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const opCard = (body.opCard ?? "").toString();
|
||||||
|
const btId = (body.btId ?? "").toString();
|
||||||
|
const usrId = (body.usrId ?? "").toString();
|
||||||
|
const plain = (body.password ?? "");
|
||||||
|
const encPass = (body.passwordEnc ?? "");
|
||||||
|
if (!opCard || !btId || (!plain && !encPass)) {
|
||||||
|
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
|
||||||
|
}
|
||||||
|
const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc;
|
||||||
|
|
||||||
|
const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: BTI, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: 0 });
|
||||||
|
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
|
||||||
|
const sendBuf = Buffer.concat([sndHeader, sndLog]);
|
||||||
|
|
||||||
|
const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
|
||||||
|
|
||||||
|
const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0,64).toString('hex') };
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
if (remaining(reply, offset) < 18) return res.json({ ok:false, error:"truncated: no header" });
|
||||||
|
// const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24)));
|
||||||
|
// result.primaryHeader = hdr;
|
||||||
|
// offset += hdr.size;
|
||||||
|
|
||||||
|
const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24)));
|
||||||
|
result.primaryHeader = hdr;
|
||||||
|
|
||||||
|
// Advance to end of header *and* align to the natural 4-byte boundary
|
||||||
|
// (C structs are commonly padded so sizeof(sRcvHeader) will often be 20, not 18)
|
||||||
|
const hdrLenAligned = (hdr.size + 3) & ~3; // e.g. 18 -> 20, 24 -> 24
|
||||||
|
// safety: ensure we have at least hdrLenAligned bytes in reply
|
||||||
|
if (!safeAdvanceOrBreak(reply, 0, hdrLenAligned, result)) {
|
||||||
|
result.error = "truncated: incomplete header (after alignment)";
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
offset = hdrLenAligned;
|
||||||
|
|
||||||
|
|
||||||
|
if (hdr.nRetCd !== SUCCESS) {
|
||||||
|
result.error = `server returned nRetCd=${hdr.nRetCd}`;
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!safeAdvanceOrBreak(reply, offset, 5+31+5+4+1+4+4+4+4, result)) {
|
||||||
|
result.error = "truncated before BTI master";
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
const btiMst = parseRcvBtiMst(reply, offset);
|
||||||
|
offset += btiMst.size;
|
||||||
|
result.btiMst = btiMst;
|
||||||
|
|
||||||
|
const details = [];
|
||||||
|
let readCount = 0;
|
||||||
|
while (readCount < hdr.nNumRecs) {
|
||||||
|
if (looksLikeHeaderAt(reply, offset)) break;
|
||||||
|
// if (!safeAdvanceOrBreak(reply, offset, 1+4+1, result)) break;
|
||||||
|
if (!safeAdvanceOrBreak(reply, offset, 1+3+1, result)) break;
|
||||||
|
|
||||||
|
const dt = parseRcvBtiDtl(reply, offset);
|
||||||
|
offset += dt.size;
|
||||||
|
details.push(dt);
|
||||||
|
readCount++;
|
||||||
|
}
|
||||||
|
result.btiDetails = details;
|
||||||
|
result.btiReadCount = readCount;
|
||||||
|
result.parsedBytes = offset;
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ ok: false, error: String(e), stack: e.stack });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- DOWNLOAD RP INFO (LOG/RPI) ----------
|
||||||
|
app.post("/download/rpinfo", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const debug = String((req.query.debug ?? "")).toLowerCase() === "1";
|
||||||
|
const opCard = (body.opCard ?? "").toString();
|
||||||
|
const btId = (body.btId ?? "").toString();
|
||||||
|
const usrId = (body.usrId ?? "").toString();
|
||||||
|
const plain = (body.password ?? "");
|
||||||
|
const encPass = (body.passwordEnc ?? "");
|
||||||
|
if (!opCard || !btId || (!plain && !encPass)) {
|
||||||
|
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
|
||||||
|
}
|
||||||
|
const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc;
|
||||||
|
|
||||||
|
const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: RPI, nNumRecsSent: 1, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: 0 });
|
||||||
|
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
|
||||||
|
const sendBuf = Buffer.concat([sndHeader, sndLog]);
|
||||||
|
|
||||||
|
const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS);
|
||||||
|
|
||||||
|
const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0,64).toString('hex') };
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
if (remaining(reply, offset) < 18) return res.json({ ok:false, error:"truncated: no top header" });
|
||||||
|
const topHdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset+24)));
|
||||||
|
offset += topHdr.size;
|
||||||
|
result.topHeader = topHdr;
|
||||||
|
if (topHdr.nRetCd !== SUCCESS) {
|
||||||
|
result.error = `server returned nRetCd=${topHdr.nRetCd}`;
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
if (debug) result.debug_afterTopHdr = hexSlice(reply, 0, 128);
|
||||||
|
|
||||||
|
// Helper to parse a section safely
|
||||||
|
function parseSection(minRecBytes, recParser, outArrName, outHeaderName) {
|
||||||
|
if (remaining(reply, offset) < 18) { result._truncated = true; return { header:null, items:[], read:0 }; }
|
||||||
|
offset = alignToPossibleHeader(reply, offset);
|
||||||
|
const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset+24)));
|
||||||
|
offset += hdr.size;
|
||||||
|
const adj = adjustForOptionalOp(reply, offset);
|
||||||
|
offset = adj.offset;
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
let read = 0;
|
||||||
|
while (read < hdr.nNumRecs) {
|
||||||
|
if (looksLikeHeaderAt(reply, offset)) break;
|
||||||
|
if (!safeAdvanceOrBreak(reply, offset, minRecBytes, result)) break;
|
||||||
|
const rec = recParser(reply, offset);
|
||||||
|
offset += rec.size;
|
||||||
|
items.push(rec);
|
||||||
|
read++;
|
||||||
|
}
|
||||||
|
result[outHeaderName] = hdr;
|
||||||
|
result[outArrName] = items;
|
||||||
|
result[outArrName.replace(/s?$/, "ReadCount")] = read;
|
||||||
|
return { header: hdr, items, read };
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPI1 .. RPI4 .. PVM
|
||||||
|
parseSection(4+1+1+15+41, parseRcvRpi1, "rpi1", "rpi1Header");
|
||||||
|
if (debug) result.debug_afterRpi1 = hexSlice(reply, Math.max(0, offset-64), 128);
|
||||||
|
|
||||||
|
parseSection(4+1+1+25+4, parseRcvRpi2, "rpi2", "rpi2Header");
|
||||||
|
if (debug) result.debug_afterRpi2 = hexSlice(reply, Math.max(0, offset-64), 128);
|
||||||
|
|
||||||
|
parseSection(4+1+1+9, parseRcvRpi3, "rpi3", "rpi3Header");
|
||||||
|
if (debug) result.debug_afterRpi3 = hexSlice(reply, Math.max(0, offset-64), 128);
|
||||||
|
|
||||||
|
parseSection(4+1+3+11+1, parseRcvRpi4, "rpi4", "rpi4Header");
|
||||||
|
if (debug) result.debug_afterRpi4 = hexSlice(reply, Math.max(0, offset-64), 128);
|
||||||
|
|
||||||
|
parseSection(4+4+4+4+4+31, parseRcvPvm, "pvms", "pvmHeader");
|
||||||
|
|
||||||
|
result.parsedBytes = offset;
|
||||||
|
|
||||||
|
// Build summary
|
||||||
|
const raceCard = {};
|
||||||
|
for (const v of result.rpi1 || []) {
|
||||||
|
const key = `${v.nRaceDt}_${v.cVenueNum}`;
|
||||||
|
raceCard[key] = { date: v.nRaceDt, venueNum: v.cVenueNum, status: v.cVenueSts, races: {}, advertisement: v.cAdvertisement };
|
||||||
|
}
|
||||||
|
for (const r of result.rpi2 || []) {
|
||||||
|
const key = `${r.nRaceDt}_${r.cVenueNum}`;
|
||||||
|
const raceKey = `${r.cRaceNum}`;
|
||||||
|
if (!raceCard[key]) raceCard[key] = { date: r.nRaceDt, venueNum: r.cVenueNum, races: {} };
|
||||||
|
raceCard[key].races[raceKey] = { raceNum: r.cRaceNum, horses: (r.cHorses || "").split(/\s+/).filter(Boolean), startTime: r.nRaceStartTm, pools: [] };
|
||||||
|
}
|
||||||
|
for (const p of result.rpi3 || []) {
|
||||||
|
const key = `${p.nRaceDt}_${p.cVenueNum}`;
|
||||||
|
const raceKey = `${p.cRaceNum}`;
|
||||||
|
if (raceCard[key] && raceCard[key].races[raceKey]) raceCard[key].races[raceKey].pools = (p.cPools || "").split("").filter(Boolean);
|
||||||
|
}
|
||||||
|
for (const s of result.rpi4 || []) {
|
||||||
|
const key = `${s.nRaceDt}_${s.cVenueNum}`;
|
||||||
|
const raceKey = `${s.cRaceNum}`;
|
||||||
|
if (raceCard[key] && raceCard[key].races[raceKey]) {
|
||||||
|
raceCard[key].races[raceKey].poolStatus = s.cPoolSts;
|
||||||
|
raceCard[key].races[raceKey].selectRaceNum = s.cSelectRaceNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.raceCard = raceCard;
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ ok: false, error: String(e), stack: e.stack });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- start HTTP server ----
|
||||||
|
const HTTP_PORT = Number(process.env.HTTP_BRIDGE_PORT || 8080);
|
||||||
|
app.listen(HTTP_PORT, () => {
|
||||||
|
console.log(`ABS HTTP bridge listening on :${HTTP_PORT}`);
|
||||||
|
console.log(`Target ABS server: ${ABS_HOST}:${ABS_PORT}`);
|
||||||
|
});
|
||||||
391
new_bridge.js
391
new_bridge.js
@ -1,391 +0,0 @@
|
|||||||
// bridge.js — HTTP→TCP bridge for ABS (ABS_POLL + LOG/LOGBT login) using 512-byte MFC-style frames
|
|
||||||
// CommonJS (Node 18+)
|
|
||||||
|
|
||||||
const express = require("express");
|
|
||||||
const net = require("net");
|
|
||||||
|
|
||||||
// ---- ABS endpoint (no server changes) ----
|
|
||||||
const ABS_HOST = process.env.ABS_HOST || "192.0.0.14";
|
|
||||||
const ABS_PORT = Number(process.env.ABS_PORT || 7000);
|
|
||||||
|
|
||||||
// ---- transaction/opcode constants (from your grep) ----
|
|
||||||
const ABS_POLL = 178; // connectivity ping
|
|
||||||
const LOG = 100; // login transaction code
|
|
||||||
const LOGBT = 6013; // login opcode
|
|
||||||
const SUCCESS = 0;
|
|
||||||
|
|
||||||
// ---- 512-byte framing ----
|
|
||||||
const PKT_SIZE = 512;
|
|
||||||
const PAYLOAD_PER_PKT = PKT_SIZE - 1;
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// we intentionally avoid global express.json() — we accept empty bodies safely
|
|
||||||
function readJsonBody(req) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const chunks = [];
|
|
||||||
req.on("data", (c) => chunks.push(c));
|
|
||||||
req.on("end", () => {
|
|
||||||
if (!chunks.length) return resolve({});
|
|
||||||
const txt = Buffer.concat(chunks).toString("utf8").trim();
|
|
||||||
if (!txt) return resolve({});
|
|
||||||
try { resolve(JSON.parse(txt)); } catch { resolve({}); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- helpers: fixed-width strings, trimming ----------
|
|
||||||
function writeFixedAscii(buf, offset, s, len) {
|
|
||||||
const str = (s ?? "").toString();
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
buf[offset + i] = i < str.length ? (str.charCodeAt(i) & 0xff) : 0x00;
|
|
||||||
}
|
|
||||||
return offset + len;
|
|
||||||
}
|
|
||||||
function readFixedAscii(buf, offset, len) {
|
|
||||||
let end = offset;
|
|
||||||
const max = offset + len;
|
|
||||||
while (end < max && buf[end] !== 0) end++;
|
|
||||||
const raw = buf.subarray(offset, end).toString("ascii");
|
|
||||||
return { value: raw.trimEnd(), next: offset + len };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- struct packers / parsers (little-endian) ----------
|
|
||||||
|
|
||||||
// sSndHeader (24 bytes)
|
|
||||||
function packSndHeader({ nTxnCd, nOpCd = 0, nNumRecsSent = 0, nNumRecsRqrd = 0, nTxnId = 0, cBtMake = 0 }) {
|
|
||||||
const b = Buffer.alloc(24, 0);
|
|
||||||
let o = 0;
|
|
||||||
b.writeInt32LE(nTxnCd, o); o += 4;
|
|
||||||
b.writeInt32LE(nOpCd, o); o += 4;
|
|
||||||
b.writeInt32LE(nNumRecsSent, o); o += 4;
|
|
||||||
b.writeInt32LE(nNumRecsRqrd, o); o += 4;
|
|
||||||
b.writeInt32LE(nTxnId, o); o += 4;
|
|
||||||
b.writeUInt8(cBtMake & 0xff, o);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sRcvHeader (18 or 24 bytes on the wire)
|
|
||||||
function parseRcvHeaderFlexible(buf) {
|
|
||||||
if (buf.length < 18) throw new Error(`Reply too short for RcvHeader: ${buf.length} bytes`);
|
|
||||||
let o = 0;
|
|
||||||
const nTxnCd = buf.readInt32LE(o); o += 4;
|
|
||||||
const nRetCd = buf.readInt32LE(o); o += 4;
|
|
||||||
const nNumRecs = buf.readInt32LE(o); o += 4;
|
|
||||||
const nTxnId = buf.readInt32LE(o); o += 4;
|
|
||||||
const cBtMake = buf.readUInt8(o);
|
|
||||||
return { nTxnCd, nRetCd, nNumRecs, nTxnId, cBtMake, size: buf.length >= 24 ? 24 : 18 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalized 18-byte header (for display)
|
|
||||||
function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = 0 }) {
|
|
||||||
const b = Buffer.alloc(18, 0);
|
|
||||||
let o = 0;
|
|
||||||
b.writeInt32LE(nTxnCd, o); o += 4;
|
|
||||||
b.writeInt32LE(nRetCd, o); o += 4;
|
|
||||||
b.writeInt32LE(nNumRecs, o); o += 4;
|
|
||||||
b.writeInt32LE(nTxnId, o); o += 4;
|
|
||||||
b.writeUInt8(cBtMake & 0xff, o);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- ABS 512-byte packetization ----------
|
|
||||||
function absSend(sock, payload) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let off = 0;
|
|
||||||
function sendNext() {
|
|
||||||
const remaining = payload.length - off;
|
|
||||||
const toCopy = Math.min(PAYLOAD_PER_PKT, Math.max(remaining, 0));
|
|
||||||
const pkt = Buffer.alloc(PKT_SIZE, 0);
|
|
||||||
pkt[0] = (off + toCopy >= payload.length) ? 48 /* '0' */ : 49 /* '1' */;
|
|
||||||
if (toCopy > 0) payload.copy(pkt, 1, off, off + toCopy);
|
|
||||||
off += toCopy;
|
|
||||||
sock.write(pkt, (err) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
if (pkt[0] === 48) return resolve(); // last
|
|
||||||
sendNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
sendNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function absRecv(sock, timeoutMs = 7000) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks = [];
|
|
||||||
let buf = Buffer.alloc(0);
|
|
||||||
const timer = timeoutMs ? setTimeout(() => done(new Error("TCP timeout")), timeoutMs) : null;
|
|
||||||
|
|
||||||
function done(err) {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
sock.off("data", onData);
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(Buffer.concat(chunks));
|
|
||||||
}
|
|
||||||
|
|
||||||
function onData(data) {
|
|
||||||
buf = Buffer.concat([buf, data]);
|
|
||||||
while (buf.length >= PKT_SIZE) {
|
|
||||||
const pkt = buf.subarray(0, PKT_SIZE);
|
|
||||||
buf = buf.subarray(PKT_SIZE);
|
|
||||||
const flag = pkt[0];
|
|
||||||
chunks.push(pkt.subarray(1));
|
|
||||||
if (flag === 48) return done(); // '0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sock.on("data", onData);
|
|
||||||
sock.on("error", (e) => done(e));
|
|
||||||
sock.on("end", () => done(new Error("Socket ended before final '0' packet")));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function absRoundtrip(payload, timeoutMs = 7000) {
|
|
||||||
const sock = new net.Socket();
|
|
||||||
await new Promise((res, rej) => sock.connect(ABS_PORT, ABS_HOST, res).once("error", rej));
|
|
||||||
try {
|
|
||||||
await absSend(sock, payload);
|
|
||||||
const replyConcat = await absRecv(sock, timeoutMs);
|
|
||||||
sock.end();
|
|
||||||
return replyConcat;
|
|
||||||
} catch (e) {
|
|
||||||
try { sock.destroy(); } catch {}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- MFC-style password "encryption" ----------
|
|
||||||
/*
|
|
||||||
Mirrors the MFC EncryptPassword():
|
|
||||||
- key = first digit of current UTC seconds (0..5, or 0..9 if seconds >= 100 ever, but it's 0..5)
|
|
||||||
- for each input char: interpret as digit (non-digit -> 0), add key, keep ones digit
|
|
||||||
- append key at the end
|
|
||||||
Result length = input.length + 1 (cPasswd field in sSndLog is 11 bytes, so 10-digit inputs fit).
|
|
||||||
*/
|
|
||||||
function encryptPasswordLikeMFC(plain) {
|
|
||||||
const sec = new Date().getUTCSeconds(); // GetSystemTime (UTC) seconds
|
|
||||||
const key = Number(String(sec)[0] || "0"); // first digit
|
|
||||||
let out = "";
|
|
||||||
const s = (plain ?? "").toString();
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
const ch = s[i];
|
|
||||||
const d = (ch >= "0" && ch <= "9") ? (ch.charCodeAt(0) - 48) : 0; // atoi on non-digit -> 0
|
|
||||||
out += String((d + key) % 10);
|
|
||||||
}
|
|
||||||
return { enc: out + String(key), key, utcSeconds: sec };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- SND/RCV: LOGIN ----------
|
|
||||||
|
|
||||||
// sSndLog (38 bytes): cUsrId[5], cOpCardCd[17], cPasswd[11], cBtId[5]
|
|
||||||
function packSndLog({ usrId = "", opCard, passwd, btId }) {
|
|
||||||
const b = Buffer.alloc(38, 0);
|
|
||||||
let o = 0;
|
|
||||||
o = writeFixedAscii(b, o, usrId, 5);
|
|
||||||
o = writeFixedAscii(b, o, opCard, 17);
|
|
||||||
o = writeFixedAscii(b, o, passwd, 11);
|
|
||||||
o = writeFixedAscii(b, o, btId, 5);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sRcvLog parser (follows sRcvHeader if nRetCd == 0)
|
|
||||||
function parseRcvLog(buf, offset = 0) {
|
|
||||||
let o = offset;
|
|
||||||
const t1 = readFixedAscii(buf, o, 20); const cDateTime = t1.value; o = t1.next;
|
|
||||||
const t2 = readFixedAscii(buf, o, 31); const cUsrNm = t2.value; o = t2.next;
|
|
||||||
const t3 = readFixedAscii(buf, o, 5 ); const cUsrId = t3.value; o = t3.next;
|
|
||||||
const t4 = readFixedAscii(buf, o, 31); const cSupNm = t4.value; o = t4.next;
|
|
||||||
const t5 = readFixedAscii(buf, o, 5 ); const cSupId = t5.value; o = t5.next;
|
|
||||||
const t6 = readFixedAscii(buf, o, 5 ); const cUsrTyp = t6.value; o = t6.next;
|
|
||||||
|
|
||||||
function rf() { const v = buf.readFloatLE(o); o += 4; return v; }
|
|
||||||
function ri() { const v = buf.readInt32LE(o); o += 4; return v; }
|
|
||||||
|
|
||||||
const fOpenBal = rf();
|
|
||||||
const fTktSalesByVoucher = rf();
|
|
||||||
const fTktSalesByCash = rf();
|
|
||||||
const fTktSalesByMemCard = rf();
|
|
||||||
const nTktSalesByVoucherCount = ri();
|
|
||||||
const nTktSalesByCashCount = ri();
|
|
||||||
const nTktSalesByMemCardCount = ri();
|
|
||||||
const fPayoutByVoucher = rf();
|
|
||||||
const fPayoutByCash = rf();
|
|
||||||
const fPayoutByMemCard = rf();
|
|
||||||
const nPayoutByVoucherCount = ri();
|
|
||||||
const nPayoutByCashCount = ri();
|
|
||||||
const nPayoutByMemCardCount = ri();
|
|
||||||
const fCancelByVoucher = rf();
|
|
||||||
const fCancelByCash = rf();
|
|
||||||
const fCancelByMemCard = rf();
|
|
||||||
const nCancelByVoucherCount = ri();
|
|
||||||
const nCancelByCashCount = ri();
|
|
||||||
const nCancelByMemCardCount = ri();
|
|
||||||
const fDeposit = rf();
|
|
||||||
const fWithdrawAmt = rf();
|
|
||||||
const fVoucherSales = rf();
|
|
||||||
const fVoucherEncash = rf();
|
|
||||||
const fCloseBal = rf();
|
|
||||||
const fSaleTarget = rf();
|
|
||||||
|
|
||||||
return {
|
|
||||||
cDateTime, cUsrNm, cUsrId, cSupNm, cSupId, cUsrTyp,
|
|
||||||
fOpenBal, fTktSalesByVoucher, fTktSalesByCash, fTktSalesByMemCard,
|
|
||||||
nTktSalesByVoucherCount, nTktSalesByCashCount, nTktSalesByMemCardCount,
|
|
||||||
fPayoutByVoucher, fPayoutByCash, fPayoutByMemCard,
|
|
||||||
nPayoutByVoucherCount, nPayoutByCashCount, nPayoutByMemCardCount,
|
|
||||||
fCancelByVoucher, fCancelByCash, fCancelByMemCard,
|
|
||||||
nCancelByVoucherCount, nCancelByCashCount, nCancelByMemCardCount,
|
|
||||||
fDeposit, fWithdrawAmt, fVoucherSales, fVoucherEncash,
|
|
||||||
fCloseBal, fSaleTarget,
|
|
||||||
size: o - offset
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- routes ----------
|
|
||||||
app.get("/", (_req, res) => res.send("ABS bridge is up"));
|
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
|
||||||
const s = new net.Socket();
|
|
||||||
s.setTimeout(1500);
|
|
||||||
s.connect(ABS_PORT, ABS_HOST, () => { s.destroy(); res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}` }); });
|
|
||||||
s.on("timeout", () => { s.destroy(); res.status(504).json({ ok: false, error: "TCP timeout" }); });
|
|
||||||
s.on("error", (e) => { s.destroy(); res.status(502).json({ ok: false, error: String(e) }); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------- ABS_POLL ----------
|
|
||||||
app.post("/abs/poll", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const body = await readJsonBody(req);
|
|
||||||
const btMake = (typeof body.btMake === "string")
|
|
||||||
? body.btMake.charCodeAt(0)
|
|
||||||
: (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00);
|
|
||||||
|
|
||||||
const header = packSndHeader({
|
|
||||||
nTxnCd: ABS_POLL, nOpCd: 0, nNumRecsSent: 0, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: btMake
|
|
||||||
});
|
|
||||||
|
|
||||||
const reply = await absRoundtrip(header, 7000);
|
|
||||||
const headerSlice = reply.subarray(0, Math.min(reply.length, 24));
|
|
||||||
const parsed = parseRcvHeaderFlexible(headerSlice);
|
|
||||||
|
|
||||||
const success = parsed.nRetCd === SUCCESS;
|
|
||||||
const normalized = {
|
|
||||||
nTxnCd: parsed.nTxnCd || ABS_POLL,
|
|
||||||
nRetCd: parsed.nRetCd,
|
|
||||||
nNumRecs: parsed.nNumRecs,
|
|
||||||
nTxnId: parsed.nTxnId,
|
|
||||||
cBtMake: parsed.cBtMake,
|
|
||||||
size: 18
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
ok: true,
|
|
||||||
target: `${ABS_HOST}:${ABS_PORT}`,
|
|
||||||
sentHeaderHex: header.toString("hex"),
|
|
||||||
replyBytes: reply.length,
|
|
||||||
replyHexFirst64: reply.subarray(0, 64).toString("hex"),
|
|
||||||
parsedRcvHeaderRaw: parsed,
|
|
||||||
normalizedRcvHeader: normalized,
|
|
||||||
normalizedHeaderHex: buildRcvHeader18(normalized).toString("hex"),
|
|
||||||
success
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
res.status(502).json({ ok: false, error: String(e) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------- LOGIN ----------
|
|
||||||
// POST /login
|
|
||||||
// Body (JSON):
|
|
||||||
// {
|
|
||||||
// "opCard": "OPCARD17", // REQUIRED (<=17)
|
|
||||||
// "password": "1234567890", // plain 0–9; will be encrypted for you
|
|
||||||
// // OR: "passwordEnc": "45673" // already-encrypted string (skip client encryption)
|
|
||||||
// "btId": "0483", // REQUIRED (<=5)
|
|
||||||
// "usrId": "", // optional (<=5)
|
|
||||||
// "btMake": 0 // optional: numeric or single-char string
|
|
||||||
// }
|
|
||||||
app.post("/login", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const body = await readJsonBody(req);
|
|
||||||
|
|
||||||
const opCard = (body.opCard ?? "").toString();
|
|
||||||
const btId = (body.btId ?? "").toString();
|
|
||||||
const usrId = (body.usrId ?? "").toString();
|
|
||||||
|
|
||||||
const plain = (body.password ?? "");
|
|
||||||
const givenEnc = (body.passwordEnc ?? "");
|
|
||||||
|
|
||||||
if (!opCard || !btId || (!plain && !givenEnc)) {
|
|
||||||
return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt if only plain is supplied (MFC-compatible)
|
|
||||||
let encInfo = null;
|
|
||||||
let passwd = givenEnc ? String(givenEnc) : (encInfo = encryptPasswordLikeMFC(String(plain))).enc;
|
|
||||||
|
|
||||||
// C field sizes: cPasswd[11] – warn if plain > 10 (enc becomes > 11)
|
|
||||||
if (!givenEnc && String(plain).length > 10) {
|
|
||||||
// still send (field will truncate), but tell caller
|
|
||||||
}
|
|
||||||
|
|
||||||
const btMake = (typeof body.btMake === "string")
|
|
||||||
? body.btMake.charCodeAt(0)
|
|
||||||
: (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00);
|
|
||||||
|
|
||||||
const sndHeader = packSndHeader({
|
|
||||||
nTxnCd: LOG, nOpCd: LOGBT, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: btMake
|
|
||||||
});
|
|
||||||
const sndLog = packSndLog({ usrId, opCard, passwd, btId });
|
|
||||||
const sendBuf = Buffer.concat([sndHeader, sndLog]);
|
|
||||||
|
|
||||||
const reply = await absRoundtrip(sendBuf, 10000);
|
|
||||||
|
|
||||||
const hdrSlice = reply.subarray(0, Math.min(reply.length, 24));
|
|
||||||
const rcvHeader = parseRcvHeaderFlexible(hdrSlice);
|
|
||||||
|
|
||||||
const json = {
|
|
||||||
ok: true,
|
|
||||||
target: `${ABS_HOST}:${ABS_PORT}`,
|
|
||||||
sentHeaderHex: sndHeader.toString("hex"),
|
|
||||||
sentBodyHex: sndLog.toString("hex"),
|
|
||||||
replyBytes: reply.length,
|
|
||||||
replyHexFirst64: reply.subarray(0, 64).toString("hex"),
|
|
||||||
rcvHeaderRaw: rcvHeader,
|
|
||||||
success: rcvHeader.nRetCd === SUCCESS
|
|
||||||
};
|
|
||||||
|
|
||||||
// include encryption debug (non-sensitive) if we did it here
|
|
||||||
if (!givenEnc && encInfo) {
|
|
||||||
json.encryption = {
|
|
||||||
used: true,
|
|
||||||
utcSeconds: encInfo.utcSeconds,
|
|
||||||
key: encInfo.key,
|
|
||||||
encPasswd: passwd
|
|
||||||
};
|
|
||||||
} else if (givenEnc) {
|
|
||||||
json.encryption = { used: false, providedPasswordEnc: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyOffset = rcvHeader.size; // 18 or 24
|
|
||||||
if (json.success && reply.length >= bodyOffset + 20) {
|
|
||||||
try {
|
|
||||||
const log = parseRcvLog(reply, bodyOffset);
|
|
||||||
json.log = log;
|
|
||||||
} catch (e) {
|
|
||||||
json.parseLogError = String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(json);
|
|
||||||
} catch (e) {
|
|
||||||
res.status(502).json({ ok: false, error: String(e) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- start HTTP server ----
|
|
||||||
const HTTP_PORT = Number(process.env.HTTP_BRIDGE_PORT || 8080);
|
|
||||||
app.listen(HTTP_PORT, () => {
|
|
||||||
console.log(`ABS HTTP bridge listening on :${HTTP_PORT}`);
|
|
||||||
console.log(`Target ABS server: ${ABS_HOST}:${ABS_PORT}`);
|
|
||||||
});
|
|
||||||
2
poll.sh
Normal file → Executable file
2
poll.sh
Normal file → Executable file
@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
curl -s -X POST http://localhost:8080/abs/poll |jq
|
curl -s -X POST http://localhost:8080/abs/poll |jq
|
||||||
sleep 2
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|||||||
4
rpinfo.sh
Executable file
4
rpinfo.sh
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
curl -sS -X POST "http://localhost:8080/download/rpinfo" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"opCard":"021804111066","password":"0660000","btId":"0483","usrId":"","btMake":0}' | jq
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// bridge.js — HTTP→TCP bridge for ABS (ABS_POLL + LOG/LOGBT login) using 512-byte MFC-style frames
|
// bridge.js — HTTP→TCP bridge for ABS (ABS_POLL + LOG/LOGBT login) using 512-byte MFC-style frames
|
||||||
// CommonJS (Node 18+)
|
// Node 18+ (CommonJS)
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const net = require("net");
|
const net = require("net");
|
||||||
@ -8,10 +8,10 @@ const net = require("net");
|
|||||||
const ABS_HOST = process.env.ABS_HOST || "192.0.0.14";
|
const ABS_HOST = process.env.ABS_HOST || "192.0.0.14";
|
||||||
const ABS_PORT = Number(process.env.ABS_PORT || 7000);
|
const ABS_PORT = Number(process.env.ABS_PORT || 7000);
|
||||||
|
|
||||||
// ---- transaction/opcode constants (from your grep) ----
|
// ---- transaction/opcode constants (from Abs.h / your grep) ----
|
||||||
const ABS_POLL = 178; // connectivity ping
|
const ABS_POLL = 178; // connectivity ping
|
||||||
const LOG = 100; // login transaction code
|
const LOG = 100; // login transaction code
|
||||||
const LOGBT = 6013; // login opcode
|
const LOGBT = 6013; // login opcode
|
||||||
const SUCCESS = 0;
|
const SUCCESS = 0;
|
||||||
|
|
||||||
// ---- 512-byte framing ----
|
// ---- 512-byte framing ----
|
||||||
@ -157,10 +157,10 @@ async function absRoundtrip(payload, timeoutMs = 7000) {
|
|||||||
// ---------- MFC-style password "encryption" ----------
|
// ---------- MFC-style password "encryption" ----------
|
||||||
/*
|
/*
|
||||||
Mirrors the MFC EncryptPassword():
|
Mirrors the MFC EncryptPassword():
|
||||||
- key = first digit of current UTC seconds (0..5, or 0..9 if seconds >= 100 ever, but it's 0..5)
|
- key = first digit of current UTC seconds
|
||||||
- for each input char: interpret as digit (non-digit -> 0), add key, keep ones digit
|
- for each input char: interpret as digit (non-digit -> 0), add key, keep ones digit
|
||||||
- append key at the end
|
- append key at the end
|
||||||
Result length = input.length + 1 (cPasswd field in sSndLog is 11 bytes, so 10-digit inputs fit).
|
Result length = input.length + 1 (cPasswd in sSndLog is 11 bytes, so 10-digit inputs fit).
|
||||||
*/
|
*/
|
||||||
function encryptPasswordLikeMFC(plain) {
|
function encryptPasswordLikeMFC(plain) {
|
||||||
const sec = new Date().getUTCSeconds(); // GetSystemTime (UTC) seconds
|
const sec = new Date().getUTCSeconds(); // GetSystemTime (UTC) seconds
|
||||||
@ -295,24 +295,12 @@ app.post("/abs/poll", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- LOGIN ----------
|
// ---------- LOGIN ----------
|
||||||
// POST /login
|
|
||||||
// Body (JSON):
|
|
||||||
// {
|
|
||||||
// "opCard": "OPCARD17", // REQUIRED (<=17)
|
|
||||||
// "password": "1234567890", // plain 0–9; will be encrypted for you
|
|
||||||
// // OR: "passwordEnc": "45673" // already-encrypted string (skip client encryption)
|
|
||||||
// "btId": "0483", // REQUIRED (<=5)
|
|
||||||
// "usrId": "", // optional (<=5)
|
|
||||||
// "btMake": 0 // optional: numeric or single-char string
|
|
||||||
// }
|
|
||||||
app.post("/login", async (req, res) => {
|
app.post("/login", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const body = await readJsonBody(req);
|
const body = await readJsonBody(req);
|
||||||
|
|
||||||
const opCard = (body.opCard ?? "").toString();
|
const opCard = (body.opCard ?? "").toString();
|
||||||
const btId = (body.btId ?? "").toString();
|
const btId = (body.btId ?? "").toString();
|
||||||
const usrId = (body.usrId ?? "").toString();
|
const usrId = (body.usrId ?? "").toString();
|
||||||
|
|
||||||
const plain = (body.password ?? "");
|
const plain = (body.password ?? "");
|
||||||
const givenEnc = (body.passwordEnc ?? "");
|
const givenEnc = (body.passwordEnc ?? "");
|
||||||
|
|
||||||
@ -324,11 +312,6 @@ app.post("/login", async (req, res) => {
|
|||||||
let encInfo = null;
|
let encInfo = null;
|
||||||
let passwd = givenEnc ? String(givenEnc) : (encInfo = encryptPasswordLikeMFC(String(plain))).enc;
|
let passwd = givenEnc ? String(givenEnc) : (encInfo = encryptPasswordLikeMFC(String(plain))).enc;
|
||||||
|
|
||||||
// C field sizes: cPasswd[11] – warn if plain > 10 (enc becomes > 11)
|
|
||||||
if (!givenEnc && String(plain).length > 10) {
|
|
||||||
// still send (field will truncate), but tell caller
|
|
||||||
}
|
|
||||||
|
|
||||||
const btMake = (typeof body.btMake === "string")
|
const btMake = (typeof body.btMake === "string")
|
||||||
? body.btMake.charCodeAt(0)
|
? body.btMake.charCodeAt(0)
|
||||||
: (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00);
|
: (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00);
|
||||||
@ -341,7 +324,7 @@ app.post("/login", async (req, res) => {
|
|||||||
|
|
||||||
const reply = await absRoundtrip(sendBuf, 10000);
|
const reply = await absRoundtrip(sendBuf, 10000);
|
||||||
|
|
||||||
const hdrSlice = reply.subarray(0, Math.min(reply.length, 24));
|
const hdrSlice = reply.subarray(0, Math.min(reply.length, 24));
|
||||||
const rcvHeader = parseRcvHeaderFlexible(hdrSlice);
|
const rcvHeader = parseRcvHeaderFlexible(hdrSlice);
|
||||||
|
|
||||||
const json = {
|
const json = {
|
||||||
@ -355,7 +338,6 @@ app.post("/login", async (req, res) => {
|
|||||||
success: rcvHeader.nRetCd === SUCCESS
|
success: rcvHeader.nRetCd === SUCCESS
|
||||||
};
|
};
|
||||||
|
|
||||||
// include encryption debug (non-sensitive) if we did it here
|
|
||||||
if (!givenEnc && encInfo) {
|
if (!givenEnc && encInfo) {
|
||||||
json.encryption = {
|
json.encryption = {
|
||||||
used: true,
|
used: true,
|
||||||
@ -367,7 +349,35 @@ app.post("/login", async (req, res) => {
|
|||||||
json.encryption = { used: false, providedPasswordEnc: true };
|
json.encryption = { used: false, providedPasswordEnc: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyOffset = rcvHeader.size; // 18 or 24
|
// --- adjust body offset to account for optional 4‑byte nOpCd (forward) ---
|
||||||
|
let bodyOffset = rcvHeader.size; // 18 or 24
|
||||||
|
if (reply.length >= bodyOffset + 4) {
|
||||||
|
const maybeOp = reply.readInt32LE(bodyOffset);
|
||||||
|
if (maybeOp === LOGBT || maybeOp === LOG || maybeOp === 0 || (maybeOp >= 1000 && maybeOp <= 10000)) {
|
||||||
|
bodyOffset += 4; // explicit opcode present
|
||||||
|
json.rcvExtraOpCd = maybeOp;
|
||||||
|
} else {
|
||||||
|
// if current doesn't look like '20' but +4 does, move forward
|
||||||
|
const looksLikeYear = reply[bodyOffset] === 0x32 && reply[bodyOffset + 1] === 0x30; // '2','0'
|
||||||
|
const looksLikeYearFwd = reply[bodyOffset + 4] === 0x32 && reply[bodyOffset + 5] === 0x30;
|
||||||
|
if (!looksLikeYear && looksLikeYearFwd) bodyOffset += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- back guard: if we landed on '/', back up 4 to include '2025' ---
|
||||||
|
if (bodyOffset >= 4 && reply[bodyOffset] === 0x2f /* '/' */) {
|
||||||
|
const couldBeYear = reply[bodyOffset - 4] === 0x32 /* '2' */ &&
|
||||||
|
reply[bodyOffset - 3] === 0x30 /* '0' */;
|
||||||
|
if (couldBeYear) bodyOffset -= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional: quick probe to verify alignment during testing
|
||||||
|
json.offsetProbe = {
|
||||||
|
bodyOffset,
|
||||||
|
around: reply.subarray(Math.max(0, bodyOffset - 8), bodyOffset + 16).toString("ascii")
|
||||||
|
};
|
||||||
|
|
||||||
|
// parse body if success
|
||||||
if (json.success && reply.length >= bodyOffset + 20) {
|
if (json.success && reply.length >= bodyOffset + 20) {
|
||||||
try {
|
try {
|
||||||
const log = parseRcvLog(reply, bodyOffset);
|
const log = parseRcvLog(reply, bodyOffset);
|
||||||
Loading…
Reference in New Issue
Block a user