import { createHash } from "node:crypto"; import { DEVICE_ADAPTER_CONTRACT_VERSION, defineDeviceAdapter, } from "../../device-adapter-runtime/src/index.mjs"; 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: "typed-service-ping-v1", exportedCommandBuilders: 1, }, routeCompatibility: { gelios: "parallel-preserved", automaticCommandFailover: false, }, }); export const ARUSNAVI_B2_ADAPTER = defineDeviceAdapter({ contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION, adapterRef: "arusnavi-b2", runtimePackageRef: "@nodedc/arusnavi-b2-adapter", profiles: [ARUSNAVI_B2_MODEL_PROFILE], createSession({ profileRef } = {}) { if (profileRef !== ARUSNAVI_B2_MODEL_PROFILE.profileRef) { throw new TypeError("b2_adapter_profile_unsupported"); } return { parseHeader: tryParseB2Header2, buildHeaderAcknowledgement: buildB2HeaderAcknowledgement, parseMessage: tryParseB2Package, buildMessageAcknowledgement(message) { return buildB2PackageAcknowledgement(message.packageNumber); }, buildTypedCommand: buildB2TypedCommand, parseTypedCommandResponse: tryParseB2TypedCommandResponse, }; }, }); 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, messageType: "telemetry.package", payloadSchemaRef: "arusnavi.internal.package-metadata.v1", payload: Object.freeze({ packageNumber, packetCount, byteLength: offset + 1, packageDigest: `sha256:${createHash("sha256") .update(input.subarray(0, offset + 1)) .digest("hex")}`, }), }); } 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 buildB2TypedCommand({ commandType, accessCode } = {}) { if (commandType !== "service.ping") { throw new TypeError("b2_typed_command_unsupported"); } if (typeof accessCode !== "string" || !/^\d{6}$/.test(accessCode)) { throw new TypeError("b2_command_access_code_invalid"); } return Buffer.from(`${accessCode}*SERV*1.1`, "ascii"); } export function tryParseB2TypedCommandResponse(input, { commandType } = {}) { assertBuffer(input, "b2_command_response_buffer_required"); if (commandType !== "service.ping") { throw new TypeError("b2_typed_command_unsupported"); } const expected = Buffer.from("SERV OK", "ascii"); const compared = Math.min(input.length, expected.length); if (!input.subarray(0, compared).equals(expected.subarray(0, compared))) { return Object.freeze({ status: "not-command" }); } if (input.length < expected.length) { return Object.freeze({ status: "incomplete", minimumBytes: expected.length }); } return Object.freeze({ status: "acknowledged", bytesConsumed: expected.length, resultCode: "serv_ok", }); } 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 !== "typed-service-ping-v1") { throw new TypeError("b2_command_transport_profile_invalid"); } 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); }