263 lines
6.9 KiB
JavaScript
263 lines
6.9 KiB
JavaScript
export const ARUSNAVI_INTERNAL_SPECIFICATION_REF =
|
|
"arusnavi.internal.protocol-sheet.gid-12.v1";
|
|
|
|
const HEADER2_LENGTH = 10;
|
|
const HEADER_START = 0xff;
|
|
const HEADER2_GPRS_VERSION = 0x23;
|
|
const PACKAGE_START = 0x5b;
|
|
const PACKAGE_END = 0x5d;
|
|
const SERVER_COMMAND_START = 0x7b;
|
|
const SERVER_COMMAND_END = 0x7d;
|
|
const MIN_PACKAGE_NUMBER = 0x01;
|
|
const MAX_PACKAGE_NUMBER = 0xfb;
|
|
const PACKET_FIXED_LENGTH = 8;
|
|
const MAX_PACKET_DATA_LENGTH = 32 * 1024;
|
|
const MAX_PACKAGE_LENGTH = 64 * 1024;
|
|
|
|
export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
|
schemaVersion: "nodedc.device-model-profile.v1",
|
|
profileRef: "arusnavi.b2.internal.v1",
|
|
vendor: "ARUSNAVI",
|
|
model: "B2",
|
|
deviceType: "tracker",
|
|
protocol: "INTERNAL",
|
|
monitoringServerSlots: 4,
|
|
serverIdentity: {
|
|
kind: "imei",
|
|
source: "modem",
|
|
trust: "claimed-not-ownership-proof",
|
|
},
|
|
bootstrap: {
|
|
operatorSurface: "ARUSNAVI_WEB_OR_LOCAL_CONFIGURATOR",
|
|
platformCredentialRequired: false,
|
|
preserveExistingRoutes: true,
|
|
},
|
|
framing: {
|
|
status: "verified-read-only",
|
|
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
|
headerVersion: "HEADER2_GPRS_0x23",
|
|
headerBytes: HEADER2_LENGTH,
|
|
maxBufferedBytes: MAX_PACKAGE_LENGTH,
|
|
},
|
|
acknowledgement: {
|
|
header: "server-time-only",
|
|
package: "package-number-only",
|
|
},
|
|
commandTransport: {
|
|
status: "disabled",
|
|
exportedCommandBuilders: 0,
|
|
},
|
|
routeCompatibility: {
|
|
gelios: "parallel-preserved",
|
|
automaticCommandFailover: false,
|
|
},
|
|
});
|
|
|
|
export function tryParseB2Header2(input) {
|
|
assertBuffer(input, "b2_header_buffer_required");
|
|
if (input.length < HEADER2_LENGTH) {
|
|
return Object.freeze({
|
|
status: "incomplete",
|
|
minimumBytes: HEADER2_LENGTH,
|
|
});
|
|
}
|
|
if (input[0] !== HEADER_START) {
|
|
throw new TypeError("b2_header_start_invalid");
|
|
}
|
|
if (input[1] !== HEADER2_GPRS_VERSION) {
|
|
throw new TypeError("b2_header_version_unsupported");
|
|
}
|
|
|
|
const identifier = input.readBigUInt64LE(2).toString(10);
|
|
if (!/^\d{15}$/.test(identifier)) {
|
|
throw new TypeError("b2_header_imei_invalid");
|
|
}
|
|
|
|
return Object.freeze({
|
|
status: "complete",
|
|
bytesConsumed: HEADER2_LENGTH,
|
|
identifier: Object.freeze({
|
|
kind: "imei",
|
|
value: identifier,
|
|
trust: "claimed-not-ownership-proof",
|
|
}),
|
|
evidence: Object.freeze({
|
|
transport: "tcp",
|
|
bytesObserved: HEADER2_LENGTH,
|
|
framingStatus: "verified",
|
|
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function buildB2HeaderAcknowledgement(unixSeconds) {
|
|
const timestamp = normalizeUInt32(
|
|
unixSeconds,
|
|
"b2_header_ack_timestamp_invalid",
|
|
);
|
|
const commandData = Buffer.allocUnsafe(4);
|
|
commandData.writeUInt32LE(timestamp);
|
|
return Buffer.from([
|
|
SERVER_COMMAND_START,
|
|
commandData.length,
|
|
0x00,
|
|
checksum(commandData),
|
|
...commandData,
|
|
SERVER_COMMAND_END,
|
|
]);
|
|
}
|
|
|
|
export function tryParseB2Package(input) {
|
|
assertBuffer(input, "b2_package_buffer_required");
|
|
if (input.length === 0) {
|
|
return Object.freeze({ status: "incomplete", minimumBytes: 1 });
|
|
}
|
|
if (input[0] !== PACKAGE_START) {
|
|
throw new TypeError("b2_package_start_invalid");
|
|
}
|
|
if (input.length < 3) {
|
|
return Object.freeze({ status: "incomplete", minimumBytes: 3 });
|
|
}
|
|
|
|
const packageNumber = input[1];
|
|
if (
|
|
packageNumber < MIN_PACKAGE_NUMBER
|
|
|| packageNumber > MAX_PACKAGE_NUMBER
|
|
) {
|
|
throw new TypeError("b2_package_number_invalid");
|
|
}
|
|
|
|
let offset = 2;
|
|
let packetCount = 0;
|
|
while (true) {
|
|
if (offset >= MAX_PACKAGE_LENGTH) {
|
|
throw new TypeError("b2_package_length_exceeded");
|
|
}
|
|
if (offset >= input.length) {
|
|
return Object.freeze({
|
|
status: "incomplete",
|
|
minimumBytes: offset + 1,
|
|
});
|
|
}
|
|
if (input[offset] === PACKAGE_END) {
|
|
if (packetCount === 0) {
|
|
throw new TypeError("b2_package_empty");
|
|
}
|
|
return Object.freeze({
|
|
status: "complete",
|
|
bytesConsumed: offset + 1,
|
|
packageNumber,
|
|
packetCount,
|
|
});
|
|
}
|
|
if (input.length - offset < 3) {
|
|
return Object.freeze({
|
|
status: "incomplete",
|
|
minimumBytes: offset + 3,
|
|
});
|
|
}
|
|
|
|
const dataLength = input.readUInt16LE(offset + 1);
|
|
if (dataLength > MAX_PACKET_DATA_LENGTH) {
|
|
throw new TypeError("b2_packet_data_length_exceeded");
|
|
}
|
|
const packetLength = PACKET_FIXED_LENGTH + dataLength;
|
|
const packetEnd = offset + packetLength;
|
|
if (packetEnd + 1 > MAX_PACKAGE_LENGTH) {
|
|
throw new TypeError("b2_package_length_exceeded");
|
|
}
|
|
if (input.length < packetEnd) {
|
|
return Object.freeze({
|
|
status: "incomplete",
|
|
minimumBytes: packetEnd,
|
|
});
|
|
}
|
|
|
|
const expectedChecksum = checksum(
|
|
input.subarray(offset + 3, packetEnd - 1),
|
|
);
|
|
if (input[packetEnd - 1] !== expectedChecksum) {
|
|
throw new TypeError("b2_packet_checksum_invalid");
|
|
}
|
|
packetCount += 1;
|
|
offset = packetEnd;
|
|
}
|
|
}
|
|
|
|
export function buildB2PackageAcknowledgement(packageNumber) {
|
|
const normalized = Number(packageNumber);
|
|
if (
|
|
!Number.isSafeInteger(normalized)
|
|
|| normalized < MIN_PACKAGE_NUMBER
|
|
|| normalized > MAX_PACKAGE_NUMBER
|
|
) {
|
|
throw new TypeError("b2_package_ack_number_invalid");
|
|
}
|
|
return Buffer.from([
|
|
SERVER_COMMAND_START,
|
|
0x00,
|
|
normalized,
|
|
SERVER_COMMAND_END,
|
|
]);
|
|
}
|
|
|
|
export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
|
|
if (profile.monitoringServerSlots !== 4) {
|
|
throw new TypeError("b2_server_slot_count_invalid");
|
|
}
|
|
if (profile.protocol !== "INTERNAL") {
|
|
throw new TypeError("b2_protocol_invalid");
|
|
}
|
|
if (profile.serverIdentity.kind !== "imei") {
|
|
throw new TypeError("b2_identity_kind_invalid");
|
|
}
|
|
if (profile.framing.status !== "verified-read-only") {
|
|
throw new TypeError("b2_framing_must_be_verified");
|
|
}
|
|
if (
|
|
profile.framing.specificationRef
|
|
!== ARUSNAVI_INTERNAL_SPECIFICATION_REF
|
|
) {
|
|
throw new TypeError("b2_framing_specification_invalid");
|
|
}
|
|
if (profile.commandTransport.status !== "disabled") {
|
|
throw new TypeError("b2_command_transport_must_be_disabled");
|
|
}
|
|
if (profile.routeCompatibility.gelios !== "parallel-preserved") {
|
|
throw new TypeError("b2_gelios_route_must_be_preserved");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function checksum(input) {
|
|
let value = 0;
|
|
for (const byte of input) value = (value + byte) & 0xff;
|
|
return value;
|
|
}
|
|
|
|
function normalizeUInt32(value, errorCode) {
|
|
const normalized = Number(value);
|
|
if (
|
|
!Number.isSafeInteger(normalized)
|
|
|| normalized < 0
|
|
|| normalized > 0xffffffff
|
|
) {
|
|
throw new TypeError(errorCode);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function assertBuffer(input, errorCode) {
|
|
if (!Buffer.isBuffer(input)) {
|
|
throw new TypeError(errorCode);
|
|
}
|
|
}
|
|
|
|
function deepFreeze(value) {
|
|
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
|
|
return value;
|
|
}
|
|
Object.values(value).forEach(deepFreeze);
|
|
return Object.freeze(value);
|
|
}
|