| function isObject(value) { |
| return value !== null && typeof value === "object" && !Array.isArray(value); |
| } |
|
|
| function toNullableNumber(value) { |
| if (value === null || value === undefined || value === "") { |
| return null; |
| } |
| if (typeof value === "number" && Number.isFinite(value)) { |
| return value; |
| } |
| if (typeof value === "string") { |
| const parsed = Number(value); |
| return Number.isFinite(parsed) ? parsed : null; |
| } |
| return null; |
| } |
|
|
| function toNullableBoolean(value) { |
| if (value === null || value === undefined || value === "") { |
| return null; |
| } |
| if (typeof value === "boolean") { |
| return value; |
| } |
| if (typeof value === "number") { |
| if (value === 1) { |
| return true; |
| } |
| if (value === 0) { |
| return false; |
| } |
| } |
| if (typeof value === "string") { |
| const lower = value.trim().toLowerCase(); |
| if (["true", "1", "yes", "on"].includes(lower)) { |
| return true; |
| } |
| if (["false", "0", "no", "off"].includes(lower)) { |
| return false; |
| } |
| } |
| return null; |
| } |
|
|
| function normalizeTimestamp(payload) { |
| const raw = |
| payload.sourceTs || |
| payload.timestamp || |
| payload.ts || |
| payload.time || |
| payload.occurredAt || |
| null; |
|
|
| if (raw === null) { |
| return new Date(); |
| } |
|
|
| const date = new Date(raw); |
| if (!Number.isNaN(date.getTime())) { |
| return date; |
| } |
|
|
| const asNumber = Number(raw); |
| if (Number.isFinite(asNumber)) { |
| const millis = asNumber > 1e12 ? asNumber : asNumber * 1000; |
| const fromMillis = new Date(millis); |
| if (!Number.isNaN(fromMillis.getTime())) { |
| return fromMillis; |
| } |
| } |
|
|
| return new Date(); |
| } |
|
|
| function validateAndNormalizeTelemetry(topicInfo, payload) { |
| const errors = []; |
|
|
| if (!isObject(payload)) { |
| return { |
| valid: false, |
| errors: ["Payload must be a JSON object"], |
| normalized: null, |
| }; |
| } |
|
|
| const env = isObject(payload.env) ? payload.env : {}; |
| const gas = isObject(payload.gas) ? payload.gas : {}; |
| const motion = isObject(payload.motion) ? payload.motion : {}; |
|
|
| const gpsCandidate = isObject(payload.gps) |
| ? payload.gps |
| : isObject(payload.location) |
| ? payload.location |
| : {}; |
|
|
| const statusCandidate = isObject(payload.status) |
| ? payload.status |
| : isObject(payload.system) |
| ? payload.system |
| : {}; |
|
|
| const temperatureC = toNullableNumber(env.temperatureC); |
| const gasRaw = toNullableNumber(gas.mq2Raw); |
| const shock = toNullableBoolean(motion.shock); |
|
|
| if (temperatureC === null) { |
| errors.push("env.temperatureC must be a number"); |
| } |
| if (gasRaw === null) { |
| errors.push("gas.mq2Raw must be a number"); |
| } |
| if (shock === null) { |
| errors.push("motion.shock must be a boolean"); |
| } |
|
|
| if (payload.truckId && String(payload.truckId) !== String(topicInfo.truckCode)) { |
| errors.push("Payload truckId does not match topic truck segment"); |
| } |
|
|
| if ( |
| payload.containerId && |
| String(payload.containerId) !== String(topicInfo.containerCode) |
| ) { |
| errors.push("Payload containerId does not match topic container segment"); |
| } |
|
|
| const normalized = { |
| seq: Math.max(0, Math.trunc(toNullableNumber(payload.seq) || 0)), |
| sourceTs: normalizeTimestamp(payload).toISOString(), |
| gpsLat: toNullableNumber(gpsCandidate.lat), |
| gpsLon: toNullableNumber(gpsCandidate.lon), |
| speedKph: toNullableNumber(gpsCandidate.speedKph), |
| temperatureC, |
| humidityPct: toNullableNumber(env.humidityPct), |
| pressureHpa: toNullableNumber(env.pressureHpa), |
| tiltDeg: toNullableNumber(motion.tiltDeg), |
| shock: Boolean(shock), |
| gasRaw, |
| gasAlert: Boolean(toNullableBoolean(gas.alert)), |
| sdOk: toNullableBoolean(statusCandidate.sdOk), |
| gpsFix: |
| toNullableBoolean(statusCandidate.gpsFix) ?? |
| toNullableBoolean(gpsCandidate.gpsFix) ?? |
| toNullableBoolean(payload.gpsFix), |
| uplink: statusCandidate.uplink ? String(statusCandidate.uplink) : "unknown", |
| rawPayload: payload, |
| }; |
|
|
| return { |
| valid: errors.length === 0, |
| errors, |
| normalized, |
| }; |
| } |
|
|
| module.exports = { |
| validateAndNormalizeTelemetry, |
| }; |
|
|