// flymaster-client.js // WebSerial implementation of the Flymaster GPS protocol // Make FlymasterClient available globally window.FlymasterClient = class FlymasterClient { /** * Convert a Uint8Array to a hex string * @param {Uint8Array} bytes - The bytes to convert * @returns {string} - The hex string representation */ bytesToHexString(bytes) { if (!bytes || bytes.length === 0) return ''; return Array.from(bytes) .map(byte => byte.toString(16).padStart(2, '0')) .join(' '); } constructor() { // Constants this.BAUDRATE = 57600; this.XON = 0x11; this.XOFF = 0x13; this.MAX_LINE = 90; this.DOWN_TIMEOUT = 1000; // 1 second in milliseconds (matching C++ implementation) // Packet IDs this.PACKET_FLIGHT_INFO = 0xa0a0; this.PACKET_KEY_POSITION = 0xa1a1; this.PACKET_DELTA_POSITION = 0xa2a2; this.PACKET_END_MARKER = 0xa3a3; // Acknowledgment bytes this.ACK_POSITIVE = 0xb1; this.ACK_NEGATIVE = 0xb3; // Serial port and streams this.port = null; this.reader = null; this.writer = null; // Device information this.gpsname = ""; this.gpsunitid = 0; this.saved_tracks = []; this.selected_tracks = []; this.flightInfo = null; } /** * Request port access and open connection */ async connect() { try { // Request a port and open it this.port = await navigator.serial.requestPort(); await this.port.open({ baudRate: this.BAUDRATE, dataBits: 8, stopBits: 1, parity: "none", flowControl: "none" }); // Get reader and writer this.reader = this.port.readable.getReader(); this.writer = this.port.writable.getWriter(); console.log("Connected to device"); return true; } catch (error) { console.error("Connection failed:", error); return false; } } /** * Close the connection */ async disconnect() { if (this.reader) { await this.reader.releaseLock(); this.reader = null; } if (this.writer) { await this.writer.releaseLock(); this.writer = null; } if (this.port && this.port.readable) { await this.port.close(); this.port = null; } console.log("Disconnected from device"); } /** * Initialize the GPS device * This matches the C++ implementation in FlymasterGps::init_gps() */ async initGps() { try { // Get device information const result = await this.sendCommand("PFMSNP"); this.gpsname = result[0]; // Compute unit id as a XOR of the result (matching C++ implementation) this.gpsunitid = 0; for (let i = 0; i < result.length; i++) { for (let j = 0; j < result[i].length; j++) { this.gpsunitid ^= result[i].charCodeAt(j) << ((j % 4) * 8); } } // Download track list await this.sendSmplCommand("PFMDNL", ["LST"]); this.saved_tracks = []; let totaltracks = 0; do { const trackres = await this.receiveData("PFMLST"); totaltracks = parseInt(trackres[0]); const date = trackres[2]; const start = trackres[3]; const duration = trackres[4]; // Parse date and time const [day, month, year] = date.split('.').map(n => parseInt(n)); const [hour, minute, second] = start.split(':').map(n => parseInt(n)); // Create date object (note: month is 0-indexed in JS Date) const startDate = new Date(2000 + year, month - 1, day, hour, minute, second); // Parse duration const [dHour, dMinute, dSecond] = duration.split(':').map(n => parseInt(n)); // Calculate end date const endDate = new Date(startDate.getTime() + (dHour * 3600 + dMinute * 60 + dSecond) * 1000); this.saved_tracks.push({ startDate: startDate, endDate: endDate }); } while (this.saved_tracks.length < totaltracks); console.log(`Initialized GPS: ${this.gpsname}, found ${this.saved_tracks.length} tracks`); return this.saved_tracks; } catch (error) { console.error("Error in initGps:", error); throw error; } } /** * Read a packet from the device * This matches the C++ implementation in FlymasterGps::read_packet() */ async readPacket() { try { // Read initial packet data (at least header) const { value: initialBytes, done: initialDone } = await this.reader.read(); if (initialDone || !initialBytes || initialBytes.length < 3) { // At minimum: 2 bytes ID + 1 byte length throw new Error("Failed to read packet header"); } console.debug("Received initial bytes:", this.bytesToHexString(initialBytes)); // Extract packet ID (first 2 bytes) const packetId = initialBytes[0] + (initialBytes[1] << 8); if (packetId === this.PACKET_END_MARKER) { return { end: true }; } // Extract length (3rd byte) const length = initialBytes[2]; // Calculate total expected packet size: 3 bytes header + data length + 1 byte checksum const totalExpectedSize = 3 + length + 1; // Create a buffer for the complete packet let packetBytes = new Uint8Array(totalExpectedSize); // Copy initial bytes to the packet buffer let bytesReceived = Math.min(initialBytes.length, totalExpectedSize); packetBytes.set(initialBytes.slice(0, bytesReceived), 0); // Continue reading until we have the complete packet while (bytesReceived < totalExpectedSize) { const { value: moreBytes, done: moreDone } = await this.reader.read(); if (moreDone) { throw new Error("Connection closed while reading packet data"); } if (!moreBytes || moreBytes.length === 0) { continue; } console.debug("Received more bytes:", this.bytesToHexString(moreBytes)); // Calculate how many more bytes we need and how many we can copy const bytesNeeded = totalExpectedSize - bytesReceived; const bytesToCopy = Math.min(moreBytes.length, bytesNeeded); // Copy the bytes to the packet buffer packetBytes.set(moreBytes.slice(0, bytesToCopy), bytesReceived); bytesReceived += bytesToCopy; } console.debug("Complete packet assembled:", this.bytesToHexString(packetBytes)); // Extract data (next 'length' bytes after header) const data = new Uint8Array(length); for (let i = 0; i < length; i++) { data[i] = packetBytes[3 + i]; } // Extract checksum (last byte after data) const cksum = packetBytes[3 + length]; // Compute checksum let c_cksum = length; for (let i = 0; i < data.length; i++) { c_cksum ^= data[i]; } if (c_cksum !== cksum) { // Send negative acknowledgment await this.writer.write(new Uint8Array([this.ACK_NEGATIVE])); throw new Error("Data checksum error"); } else { await this.writer.write(new Uint8Array([this.ACK_POSITIVE])); } return { packetId, data }; } catch (error) { console.error("Error in readPacket:", error); throw error; } } /** * Download a single track * This matches the C++ implementation in FlymasterGps::download_strack() */ async downloadStrack(trackIndex, progressCallback = null) { try { const track = this.saved_tracks[trackIndex]; if (!track) { throw new Error(`Track ${trackIndex} not found`); } // Reset flight info before downloading this.flightInfo = null; const result = []; // Format date for command const startDate = track.startDate; const year = startDate.getFullYear() % 100; const month = (startDate.getMonth() + 1).toString().padStart(2, '0'); const day = startDate.getDate().toString().padStart(2, '0'); const hour = startDate.getHours().toString().padStart(2, '0'); const minute = startDate.getMinutes().toString().padStart(2, '0'); const second = startDate.getSeconds().toString().padStart(2, '0'); const timestamp = `${year}${month}${day}${hour}${minute}${second}`; // Send download command await this.sendSmplCommand("PFMDNL", [timestamp]); let newTrack = true; let basePos = null; let pktCount = 0; let pcounter = 0; const expCount = Math.floor((track.endDate - track.startDate) / 1000); while (true) { const packet = await this.readPacket(); // Check if end of transmission if (packet.end) { break; } if (packet.packetId === this.PACKET_FLIGHT_INFO) { // Flight information packet this.flightInfo = this.parseFlightInfo(packet.data); console.debug("Received flight info:", this.flightInfo); } else if (packet.packetId === this.PACKET_KEY_POSITION) { // Key position packet basePos = this.parseKeyPosition(packet.data); result.push(this.makePoint(basePos, newTrack)); newTrack = false; } else if (packet.packetId === this.PACKET_DELTA_POSITION) { // Delta position packet const deltas = this.parseDeltaPositions(packet.data); for (const delta of deltas) { // Apply delta to base position basePos.fix = delta.fix; basePos.latitude += delta.latoff; basePos.longitude += delta.lonoff; basePos.gpsaltitude += delta.gpsaltoff; basePos.baro += delta.baroff; basePos.time += delta.timeoff; pktCount += delta.timeoff; if (progressCallback && !(pcounter++ % 60)) { const continueDownload = progressCallback(pktCount, expCount); if (!continueDownload) { // Send negative acknowledgment await this.writer.write(new Uint8Array([this.ACK_NEGATIVE])); throw new Error("Download cancelled"); } } result.push(this.makePoint(basePos, false)); } } } return result; } catch (error) { console.error("Error in downloadStrack:", error); throw error; } } /** * Download all selected tracks * This matches the C++ implementation in FlymasterGps::download_tracklog() */ async downloadTracklog(progressCallback = null) { try { const result = []; // Sort tracks from higher index to lower (older date first) // So that the resulting array is time sorted this.selected_tracks.sort((a, b) => b - a); for (let i = 0; i < this.selected_tracks.length; i++) { const points = await this.downloadStrack(this.selected_tracks[i], progressCallback); result.push(...points); } return result; } catch (error) { console.error("Error in downloadTracklog:", error); throw error; } } /** * Parse a flight info packet */ parseFlightInfo(data) { if (!data || data.length < 58) { throw new Error("Invalid flight info data"); } const view = new DataView(data.buffer); // Extract string fields const decoder = new TextDecoder('ascii'); const compnum = decoder.decode(data.slice(8, 16)).replace(/\0/g, ''); const pilotname = decoder.decode(data.slice(16, 31)).replace(/\0/g, ''); const gliderbrand = decoder.decode(data.slice(31, 46)).replace(/\0/g, ''); const glidermodel = decoder.decode(data.slice(46, 61)).replace(/\0/g, ''); return { sw_version: view.getUint16(0, true), // little-endian hw_version: view.getUint16(2, true), // little-endian serial: view.getUint32(4, true), // little-endian compnum: compnum, pilotname: pilotname, gliderbrand: gliderbrand, glidermodel: glidermodel }; } /** * Parse a key position packet */ parseKeyPosition(data) { if (!data || data.length < 17) { throw new Error("Invalid key position data"); } const view = new DataView(data.buffer); return { fix: view.getInt8(0), latitude: view.getInt32(1, true), // little-endian longitude: view.getInt32(5, true), // little-endian gpsaltitude: view.getInt16(9, true), // little-endian baro: view.getInt16(11, true), // little-endian time: view.getUint32(13, true) // little-endian }; } /** * Parse delta positions from a packet */ parseDeltaPositions(data) { if (!data) { throw new Error("Invalid delta position data"); } const deltas = []; const DELTA_SIZE = 6; // Size of each delta structure const view = new DataView(data.buffer, data.byteOffset, data.byteLength); for (let i = 0; i + DELTA_SIZE <= data.length; i += DELTA_SIZE) { const delta = { fix: view.getInt8(i), latoff: view.getInt8(i + 1), lonoff: view.getInt8(i + 2), gpsaltoff: view.getInt8(i + 3), baroff: view.getInt8(i + 4), timeoff: view.getUint8(i + 5) }; deltas.push(delta); } return deltas; } /** * Convert a position to a trackpoint * This matches the C++ implementation in make_point() */ makePoint(pos, newTrack) { return { lat: pos.latitude / 60000.0, lon: -pos.longitude / 60000.0, gpsalt: pos.gpsaltitude, baroalt: (1 - Math.pow(Math.abs((pos.baro / 10.0) / 1013.25), 0.190284)) * 44307.69, time: pos.time + 946684800, // Convert from Y2K epoch to Unix epoch new_trk: newTrack }; } /** * Generate NMEA command with checksum * This matches the C++ implementation in NMEAGps::gen_command() */ genCommand(command, parameters = []) { let data = '$' + command + ','; let cksum = 0; // Calculate checksum for command for (let i = 0; i < command.length; i++) { cksum ^= command.charCodeAt(i); } cksum ^= ','.charCodeAt(0); // Add parameters and update checksum for (let i = 0; i < parameters.length; i++) { data += parameters[i]; // Update checksum with parameter for (let j = 0; j < parameters[i].length; j++) { cksum ^= parameters[i].charCodeAt(j); } data += ','; cksum ^= ','.charCodeAt(0); } // Add checksum as hexadecimal data += '*' + cksum.toString(16).padStart(2, '0').toUpperCase() + '\r\n'; return data; } /** * Send a command and get response * This matches the C++ implementation in NMEAGps::send_command() */ async sendCommand(command, parameters = []) { await this.sendSmplCommand(command, parameters); return await this.receiveData(command); } /** * Send a command without waiting for response * This matches the C++ implementation in NMEAGps::send_smpl_command() */ async sendSmplCommand(command, parameters = []) { const cmdStr = this.genCommand(command, parameters); const encoder = new TextEncoder(); const data = encoder.encode(cmdStr); await this.writer.write(data); console.debug("Sent command", cmdStr); } /** * Read data from the device until a valid response is received * This matches the C++ implementation in NMEAGps::receive_data() */ async receiveData(command) { const decoder = new TextDecoder(); let received = 0; let recv_cmd = ""; let param = ""; let result = []; let incmd = false; let cksum = 0; const startTime = Date.now(); while (Date.now() - startTime < this.DOWN_TIMEOUT) { const { value, done } = await this.reader.read(); if (done) { break; } for (let i = 0; i < value.length; i++) { const ch = value[i]; received++; // Ignore XON, XOFF if (ch === this.XON || ch === this.XOFF) { continue; } if (ch === 0x24) { // '$' incmd = true; cksum = 0; param = ""; recv_cmd = ""; result = []; continue; } if (!incmd) { continue; } if (ch !== 0x2A) { // Not '*' cksum ^= ch; } if (ch === 0x2C || ch === 0x2A) { // ',' or '*' if (param.length) { if (!recv_cmd.length) { recv_cmd = param; } else { result.push(param); } } param = ""; if (ch === 0x2A) { // '*' // Read checksum (2 hex characters) const cksum_s = String.fromCharCode(value[++i]) + String.fromCharCode(value[++i]); const cksum_r = parseInt(cksum_s, 16); // Skip CR, LF i += 2; if (cksum_r === cksum && recv_cmd === command) { return result; } } continue; } param += String.fromCharCode(ch); } } throw new Error(`Timeout waiting for response to ${command}`); } /** * Get list of available tracks */ getTrackList() { return this.saved_tracks.map((track, index) => { return { index: index, startDate: track.startDate, endDate: track.endDate, duration: Math.floor((track.endDate - track.startDate) / 1000) }; }); } /** * Select tracks for download */ selectTracks(indices) { this.selected_tracks = indices; } /** * Download all selected tracks */ async downloadSelectedTracks(progressCallback = null) { return await this.downloadTracklog(progressCallback); } /** * Get the flight info data * @returns {Object|null} The flight info data or null if not available */ getFlightInfo() { return this.flightInfo; } } // Export the class if (typeof module !== 'undefined' && module.exports) { module.exports = { FlymasterClient }; } else { window.FlymasterClient = FlymasterClient; }