const fs = require('fs-extra'), path = require('path'), utils = require('./utils'), polyUtil = require('./poly_util'), FILE = require('./file_constants'), { SystemTypes } = require('./constants'), debug = require('debug')('agm:file_satlog'); const POL = ".POL"; const PAT = ".PAT"; const INC = "INC"; const EXC = "EXC"; const SWIDTH = "SWIDTH"; // Constants for file formatting const CRLF = '\r\n'; // Windows line ending as per JOB format specification function getItem(val, pos) { return val[pos] ? val[pos].trim() : ''; } function readSatLogJob(filePath, areaTypeAndFiles, ops, cb) { let firstCol, fields, lat, lon, valueF, swath = 0, isMetric = false, newPolygon = false, sprayPoly = false; let cornerSets = [], sprayAreas = [], xclAreas = []; const bareFilename = utils.normalizeName(utils.bareFilename(areaTypeAndFiles.area)); return fs.readFile(path.join(filePath, areaTypeAndFiles.area)) .then((data) => { let areas, lines = data.toString().split("\n"); for (const i in lines) { if (sprayAreas.length >= FILE.MAX_ITEM || xclAreas.length >= FILE.MAX_ITEM) break; fields = lines[i].trim().split(new RegExp('\\s+')); firstCol = getItem(fields, 0); if (!firstCol.length) continue; if (firstCol === POL || firstCol === PAT || i == lines.length - 1) { newPolygon = (firstCol === POL) ? true : false; if (cornerSets.length >= 3) { if (sprayPoly) { areas = polyUtil.createAreas(0, cornerSets, bareFilename, ops.sprZoneColor, ops.appRate, ops.crop); if (sprayAreas.length < FILE.MAX_ITEM && !utils.isEmptyArray(areas)) sprayAreas = sprayAreas.concat(areas); } else { areas = polyUtil.createAreas(1, cornerSets, 'XCL'); if (xclAreas.length < FILE.MAX_ITEM && !utils.isEmptyArray(areas)) xclAreas = xclAreas.concat(areas); } } cornerSets = []; continue; } if (newPolygon) { if (firstCol === INC || firstCol === EXC) { sprayPoly = (firstCol === INC) ? true : false; } else { if (fields.length === 2) { lat = parseFloat(getItem(fields, 0)); lon = parseFloat(getItem(fields, 1)); if (utils.isNumber(lat) && utils.isNumber(lon)) { cornerSets.push([lon, lat]); // Revert coor order in GeoJSON } } } } else { if (firstCol === SWIDTH && fields.length > 1) { valueF = parseFloat(getItem(fields, 1)); if (utils.isNumber(valueF)) swath = valueF; if (fields.length >= 3) { isMetric = (getItem(fields, 3)).toUpperCase() === 'M' ? true : false; } continue; } } } const jobItem = { sprayAreas: sprayAreas, xclAreas: xclAreas, meta: { measureUnit: isMetric, swath: swath } }; if (!utils.isEmptyArray(xclAreas)) { jobItem['xclAreas'] = polyUtil.processXclsName(jobItem.sprayAreas, jobItem.xclAreas); } if (cb) cb(null, jobItem); else return jobItem; }) .catch(err => { if (cb) return cb(err); else throw err; }); } /** * Create a SatLoc job from Agmission spray areas and/or excluded areas * @param {Object} job - Job object containing name, sprayAreas, and excludedAreas, waypoints, etc. * @param {string} systemType - Type of the system (use SystemTypes constants) * @returns {string} Content of the satloc job file */ function createSatLocJob(job, systemType = SystemTypes.G4) { const jobUtil = require('./job_util'); const { _id, name, sprayAreas } = job; // Process buffers to excluded areas if they exist const excludedAreas = jobUtil.processBuffersToXclAreas(job); if (utils.isEmptyArray(sprayAreas) && utils.isEmptyArray(excludedAreas)) { return null; } const jobNumber = _id; // Generate internal job name based on system type const internalJobName = generateInternalJobName(name, systemType, jobNumber); let jobFileContent = `.JOB ${internalJobName} ${internalJobName}${CRLF}.VERSION 2${CRLF}`; // Add job name if provided if (name) { jobFileContent += `.JNM ${internalJobName}${CRLF}`; jobFileContent += `.LLB ${internalJobName}${CRLF}`; } function getAreaName(area) { // Extract area name from properties, fallback to default naming return area.properties?.name || area.properties?.label || ''; } function processAreas(areas, type) { if (utils.isEmptyArray(areas)) return; for (let index = 0; index < areas.length; index++) { const area = areas[index]; if (!area.geometry || !area.geometry.coordinates || area.geometry.coordinates.length === 0) { debug(`Area ${index + 1} is missing geometry or coordinates`); continue; } const polId = index + 1; // Polygon ID is index + 1 const areaName = getAreaName(area); let coordinates = area.geometry.coordinates[0]; // Remove duplicate last coordinate if it matches first (JOB format requirement) if (coordinates.length > 1 && coordinates[0][0] === coordinates[coordinates.length - 1][0] && coordinates[0][1] === coordinates[coordinates.length - 1][1]) { coordinates.pop(); } // Reverse coordinate order for exclusive areas (counterclockwise) if (type === 'EXC') { coordinates = coordinates.reverse(); } // Add polygon header with polygon ID and area name if exists if (areaName) { jobFileContent += `.POL ${polId}\t${areaName}${CRLF}`; } else { jobFileContent += `.POL ${polId} ${polId}${CRLF}`; } jobFileContent += `\t${type}${CRLF}`; // TAB indent for type // Add RGB color information (optional but common) if (type === 'INC') { jobFileContent += `\tRGB: 204,000,000, 0, 1${CRLF}`; // TAB indent, spray polygon } // Add coordinates with TAB indent for (let i = 0; i < coordinates.length; i++) { const coord = coordinates[i]; jobFileContent += `\t${utils.fixedTo(coord[1], 6)} ${utils.fixedTo(coord[0], 6)}${CRLF}`; // TAB indent, lat lon } } } // Process inclusive areas first, then exclusive areas if (!utils.isEmptyArray(sprayAreas)) { processAreas(sprayAreas, 'INC'); } if (!utils.isEmptyArray(excludedAreas)) { processAreas(excludedAreas, 'EXC'); } return jobFileContent; } /** * Generate internal job name based on SatLoc system type * @param {string} systemType - System type (use SystemTypes constants) * @param {number} jobNumber - Job number * @returns {string} Internal job name */ function generateInternalJobName(systemType, jobNumber) { return String(jobNumber); } /** * Extract job number from name string * @param {string} name - Job name * @returns {number} Job number or null */ function extractJobNumber(name) { if (!name) return null; // Try to extract number from various formats const patterns = [ /^(\d+)$/, // Pure number /^Job_?(\d+)/i, // Job123, Job_123 /(\d+)\.job$/i, // 123.job /_(\d+)$/ // ending with _123 ]; for (const pattern of patterns) { const match = name.match(pattern); if (match) { const num = parseInt(match[1], 10); // Ensure it's within valid range (1-9999 for G4) return (num >= 1 && num <= 9999) ? num : null; } } return null; } /** * Parse SatLoc log data from partner system. * This one was made for parsing exported csv file after a log file loaded into MapStar * @param {*} logBuffer The csv file content buffer * @returns {Promise} result object with structure: * { * records: [], * startTime: null, * endTime: null, * boundingBox: null, * fileSize: logBuffer.length * } */ function parseSatLocCSVData(logBuffer) { return new Promise((resolve, reject) => { try { const logContent = logBuffer.toString('utf8'); const lines = logContent.split('\n'); const result = { records: [], startTime: null, endTime: null, boundingBox: null, fileSize: logBuffer.length }; let minLat = Infinity, maxLat = -Infinity; let minLon = Infinity, maxLon = -Infinity; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // Parse SatLoc format: assume comma-separated values // Format: timestamp,latitude,longitude,altitude,speed,applicationRate,targetRate,sprayOn,temperature,humidity const parts = line.split(','); if (parts.length < 6) continue; try { const record = { timestamp: new Date(parts[0]), latitude: parseFloat(parts[1]), longitude: parseFloat(parts[2]), altitude: parts[3] ? parseFloat(parts[3]) : null, speed: parts[4] ? parseFloat(parts[4]) : null, applicationRate: parts[5] ? parseFloat(parts[5]) : null, targetRate: parts[6] ? parseFloat(parts[6]) : null, sprayOn: parts[7] ? parts[7].toLowerCase() === 'true' || parts[7] === '1' : false, temperature: parts[8] ? parseFloat(parts[8]) : null, humidity: parts[9] ? parseFloat(parts[9]) : null }; // Validate required fields if (isNaN(record.latitude) || isNaN(record.longitude)) { continue; } // Update time range if (!result.startTime || record.timestamp < result.startTime) { result.startTime = record.timestamp; } if (!result.endTime || record.timestamp > result.endTime) { result.endTime = record.timestamp; } // Update bounding box minLat = Math.min(minLat, record.latitude); maxLat = Math.max(maxLat, record.latitude); minLon = Math.min(minLon, record.longitude); maxLon = Math.max(maxLon, record.longitude); result.records.push(record); } catch (parseError) { debug(`Error parsing line ${i + 1}: ${parseError.message}`); continue; } } // Set bounding box if we have valid records if (result.records.length > 0) { result.boundingBox = { minLat, maxLat, minLon, maxLon }; } debug(`Parsed ${result.records.length} records from SatLoc log`); resolve(result); } catch (error) { debug('Error parsing SatLoc log data:', error); reject(error); } }); } module.exports = { readSatLogJob, parseLogData: parseSatLocCSVData, createSatLocJob, extractJobNumber, generateInternalJobName }