From e8080360922c9c56da6d648058a70e8a699a331f Mon Sep 17 00:00:00 2001 From: Arne Schauf Date: Fri, 6 Jun 2025 16:15:25 +0200 Subject: [PATCH] Add Flymaster GPS protocol details and enhance client logic Introduced a detailed communication protocol for Flymaster GPS to document device interactions. Enhanced the `flymaster-client.js` implementation with full protocol support, packet parsing, and added helper functions for track management, following the documented specifications. --- extract_the_protocol_for_communication_w.md | 84 +++ flymaster-client.js | 734 +++++++++++--------- flymaster-ui.html | 288 ++++++-- 3 files changed, 749 insertions(+), 357 deletions(-) create mode 100644 extract_the_protocol_for_communication_w.md diff --git a/extract_the_protocol_for_communication_w.md b/extract_the_protocol_for_communication_w.md new file mode 100644 index 0000000..a87e4b8 --- /dev/null +++ b/extract_the_protocol_for_communication_w.md @@ -0,0 +1,84 @@ + +# Flymaster GPS Communication Protocol + +Based on the code analysis, the Flymaster GPS device uses a combination of NMEA-style commands and a binary protocol for data transfer. Here's a detailed explanation of the protocol: + +## 1. Basic Communication Parameters +- **Baud Rate**: 57600 bps (defined as `BAUDRATE`) +- **Flow Control**: Uses XON (0x11) and XOFF (0x13) characters for flow control +- **Line Termination**: Commands end with CR+LF (`\r\n`) + +## 2. NMEA Command Structure +The Flymaster uses a standard NMEA-style command format for basic commands: + +``` +$,,,...*\r\n +``` + +Where: +- Commands start with `$` +- Fields are separated by commas +- The command ends with `*` followed by a two-digit hexadecimal checksum +- The checksum is calculated by XORing all characters between `$` and `*` (exclusive) +- The message ends with CR+LF + +## 3. Command Prefixes +- `PFM` prefix is used for Flymaster-specific commands +- Examples of commands: + - `PFMSNP`: Used to get device information + - `PFMDNL`: Used to download data (with parameters like "LST" for track list) + - `PFMLST`: Used to receive track list data + +## 4. Binary Protocol for Track Download +For downloading track data, a binary packet-based protocol is used: + +1. **Packet Structure**: + - 2-byte packet ID + - 1-byte length + - Variable-length data (as specified by the length byte) + - 1-byte checksum (XOR of the length and all data bytes) + +2. **Packet Types** (identified by packet ID): + - `0xa0a0`: Flight information + - `0xa1a1`: Key position data (base position) + - `0xa2a2`: Delta position data (incremental changes from base position) + - `0xa3a3`: End of transmission marker + +3. **Acknowledgment**: + - `0xb1`: Positive acknowledgment (continue sending) + - `0xb3`: Negative acknowledgment (error or cancel) + +## 5. Data Structures +The protocol uses specific data structures: +- `FM_Flight_Info`: Contains flight information +- `FM_Key_Position`: Contains base position data (latitude, longitude, altitude, etc.) +- `FM_Point_Delta`: Contains delta values for position updates + +## 6. Communication Flow + +### Initialization: +1. Set baud rate to 57600 +2. Send `PFMSNP` to get device information +3. Calculate device ID from the response + +### Track List Download: +1. Send `PFMDNL,LST` command +2. Receive track list data with `PFMLST` responses +3. Parse track information (date, start time, duration) + +### Track Data Download: +1. Send `PFMDNL,` with the timestamp of the desired track +2. Read binary packets: + - Process flight info packets (0xa0a0) + - Process key position packets (0xa1a1) + - Process delta position packets (0xa2a2) + - Send acknowledgment (0xb1) after each packet + - Continue until end marker (0xa3a3) is received + +## 7. Error Handling +- Checksum verification for both NMEA commands and binary packets +- Timeout handling (1 second timeout during download) +- Size verification for data structures +- Error reporting through exceptions + +This protocol efficiently combines text-based commands for control operations with a compact binary format for transferring large amounts of track data. \ No newline at end of file diff --git a/flymaster-client.js b/flymaster-client.js index 70a4c8a..63f363a 100644 --- a/flymaster-client.js +++ b/flymaster-client.js @@ -1,35 +1,50 @@ // flymaster-client.js // WebSerial implementation of the Flymaster GPS protocol -class FlymasterClient { +// 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 - + 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; } /** @@ -44,13 +59,13 @@ class FlymasterClient { dataBits: 8, stopBits: 1, parity: "none", - flowControl: "none" // We'll handle XON/XOFF in software + 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) { @@ -67,100 +82,402 @@ class FlymasterClient { 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() { - // Get device information - const result = await this.sendCommand("PFMSNP"); - this.gpsname = result[0]; - - // Compute unit id as a XOR of the result - 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); + 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 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; + } + + /** + * 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: data[16], + latitude: view.getInt32(0, true), // little-endian + longitude: view.getInt32(4, true), // little-endian + gpsaltitude: view.getInt16(8, true), // little-endian + baro: view.getInt16(10, true), // little-endian + time: view.getUint32(12, 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: data[i], + latoff: view.getInt8(i + 1), + lonoff: view.getInt8(i + 2), + gpsaltoff: view.getInt8(i + 3), + baroff: view.getInt8(i + 4), + timeoff: data[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; @@ -168,6 +485,7 @@ class FlymasterClient { /** * Send a command and get response + * This matches the C++ implementation in NMEAGps::send_command() */ async sendCommand(command, parameters = []) { await this.sendSmplCommand(command, parameters); @@ -176,16 +494,19 @@ class FlymasterClient { /** * 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(); @@ -195,20 +516,24 @@ class FlymasterClient { 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; - + 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 === this.XON || ch === this.XOFF) { + continue; + } + if (ch === 0x24) { // '$' incmd = true; cksum = 0; @@ -217,222 +542,45 @@ class FlymasterClient { result = []; continue; } - - if (!incmd) continue; - - if (ch !== 0x2A) // '*' + + if (!incmd) { + continue; + } + + if (ch !== 0x2A) { // Not '*' cksum ^= ch; - + } + if (ch === 0x2C || ch === 0x2A) { // ',' or '*' if (param.length) { - if (!recv_cmd.length) + if (!recv_cmd.length) { recv_cmd = param; - else + } 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) + + if (cksum_r === cksum && recv_cmd === command) { return result; + } } continue; } - - param += String.fromCharCode(ch); - } - } - - throw new Error("Timeout waiting for response"); - } - /** - * Read a binary packet from the device - */ - async readPacket() { - // Read packet ID (2 bytes) - const { value: idBytes } = await this.reader.read(); - const packetId = idBytes[0] + (idBytes[1] << 8); - - if (packetId === this.PACKET_END_MARKER) { - return { end: true }; - } - - // Read length (1 byte) - const { value: lengthByte } = await this.reader.read(); - const length = lengthByte[0]; - - // Read data - let data = new Uint8Array(length); - let bytesRead = 0; - - while (bytesRead < length) { - const { value } = await this.reader.read(); - const remaining = length - bytesRead; - const bytesToCopy = Math.min(remaining, value.length); - - data.set(value.slice(0, bytesToCopy), bytesRead); - bytesRead += bytesToCopy; - } - - // Read checksum - const { value: cksumByte } = await this.reader.read(); - const cksum = cksumByte[0]; - - // 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"); - } - - return { packetId, data }; - } - - /** - * Download a specific track - */ - async downloadTrack(trackIndex, progressCallback = null) { - const track = this.saved_tracks[trackIndex]; - 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) { - try { - const packet = await this.readPacket(); - - // Check if end of transmission - if (packet.end) break; - - if (packet.packetId === this.PACKET_FLIGHT_INFO) { - // Flight information packet - // We could parse this, but for now we'll just acknowledge it - } - 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)); - } - } - - // Send positive acknowledgment - await this.writer.write(new Uint8Array([this.ACK_POSITIVE])); - } catch (error) { - console.error("Error reading packet:", error); - break; + param += String.fromCharCode(ch); } } - - return result; - } - /** - * Parse a key position packet - */ - parseKeyPosition(data) { - const view = new DataView(data.buffer); - - return { - latitude: view.getInt32(0, true), // little-endian - longitude: view.getInt32(4, true), // little-endian - gpsaltitude: view.getInt16(8, true), // little-endian - baro: view.getInt16(10, true), // little-endian - time: view.getUint32(12, true), // little-endian - fix: data[16] - }; - } - - /** - * Parse delta positions from a packet - */ - parseDeltaPositions(data) { - const deltas = []; - const DELTA_SIZE = 9; // Size of each delta structure - - for (let i = 0; i + DELTA_SIZE <= data.length; i += DELTA_SIZE) { - deltas.push({ - fix: data[i], - latoff: new Int16Array(data.buffer.slice(i + 1, i + 3))[0], - lonoff: new Int16Array(data.buffer.slice(i + 3, i + 5))[0], - gpsaltoff: data[i + 5], - baroff: data[i + 6], - timeoff: new Uint16Array(data.buffer.slice(i + 7, i + 9))[0] - }); - } - - return deltas; - } - - /** - * Convert a position to a trackpoint - */ - 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 - }; + throw new Error(`Timeout waiting for response to ${command}`); } /** @@ -453,62 +601,22 @@ class FlymasterClient { * Select tracks for download */ selectTracks(indices) { - this.selected_tracks = indices.sort((a, b) => b - a); // Sort in descending order + this.selected_tracks = indices; } /** * Download all selected tracks */ async downloadSelectedTracks(progressCallback = null) { - let allPoints = []; - - for (const trackIndex of this.selected_tracks) { - const points = await this.downloadTrack(trackIndex, progressCallback); - allPoints = allPoints.concat(points); - } - - return allPoints; + return await this.downloadTracklog(progressCallback); } -} -// Example usage: -async function connectToFlymaster() { - const client = new FlymasterClient(); - - try { - // Connect to device - const connected = await client.connect(); - if (!connected) { - console.error("Failed to connect to device"); - return; - } - - // Initialize GPS - await client.initGps(); - - // Get track list - const tracks = client.getTrackList(); - console.log("Available tracks:", tracks); - - // Select tracks to download (e.g., the most recent one) - client.selectTracks([0]); - - // Download selected tracks with progress callback - const points = await client.downloadSelectedTracks((current, total) => { - const percent = Math.floor((current / total) * 100); - console.log(`Download progress: ${percent}%`); - return true; // Return false to cancel download - }); - - console.log(`Downloaded ${points.length} points`); - - // Disconnect - await client.disconnect(); - - return points; - } catch (error) { - console.error("Error:", error); - await client.disconnect(); + /** + * Get the flight info data + * @returns {Object|null} The flight info data or null if not available + */ + getFlightInfo() { + return this.flightInfo; } } diff --git a/flymaster-ui.html b/flymaster-ui.html index b8d7056..105ad24 100644 --- a/flymaster-ui.html +++ b/flymaster-ui.html @@ -8,6 +8,14 @@ + + + +

Flymaster GPS Client

- +

Connection

- +
{{ statusMessage }}
- +

Device Information

Name: {{ deviceInfo.name }}

Unit ID: {{ deviceInfo.unitId }}

- +

Tracks

- +
Loading tracks...
- +
- + - +
Select # Start Date End Date DurationActions
{{ track.index + 1 }} {{ formatDate(track.startDate) }} {{ formatDate(track.endDate) }} {{ formatDuration(track.duration) }} + + +
- -
- +

Download Progress

@@ -160,11 +199,50 @@
- + +

Downloaded Track Data

Total points: {{ trackPoints.length }}

- + + + +
+

Flight Information

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Software Version{{ flightInfo.sw_version }}
Hardware Version{{ flightInfo.hw_version }}
Serial Number{{ flightInfo.serial }}
Competition Number{{ flightInfo.compnum || 'Not set' }}
Pilot Name{{ flightInfo.pilotname || 'Not set' }}
Glider Brand{{ flightInfo.gliderbrand || 'Not set' }}
Glider Model{{ flightInfo.glidermodel || 'Not set' }}
+
+

Sample Points

@@ -190,6 +268,9 @@
+ +

Track Map

+
@@ -205,9 +286,10 @@ statusType: 'info', deviceInfo: null, tracks: [], - selectedTracks: [], trackPoints: [], - downloadProgress: 0 + downloadProgress: 0, + map: null, + flightInfo: null }, computed: { samplePoints() { @@ -216,19 +298,21 @@ } }, methods: { + async connect() { try { this.statusMessage = 'Connecting to device...'; this.statusType = 'info'; - + this.client = new FlymasterClient(); + const connected = await this.client.connect(); - + if (connected) { this.isConnected = true; this.statusMessage = 'Connected to device successfully!'; this.statusType = 'success'; - + // Initialize GPS await this.initializeGps(); } else { @@ -241,7 +325,7 @@ this.statusType = 'error'; } }, - + async disconnect() { try { if (this.client) { @@ -250,6 +334,7 @@ this.statusMessage = 'Disconnected from device.'; this.statusType = 'info'; this.deviceInfo = null; + this.flightInfo = null; } } catch (error) { console.error('Disconnection error:', error); @@ -257,22 +342,22 @@ this.statusType = 'error'; } }, - + async initializeGps() { try { this.isLoading = true; this.statusMessage = 'Initializing GPS...'; this.statusType = 'info'; - + await this.client.initGps(); - + this.deviceInfo = { name: this.client.gpsname, unitId: this.client.gpsunitid }; - + this.refreshTracks(); - + this.statusMessage = 'GPS initialized successfully!'; this.statusType = 'success'; } catch (error) { @@ -283,16 +368,15 @@ this.isLoading = false; } }, - + async refreshTracks() { try { this.isLoading = true; this.statusMessage = 'Loading tracks...'; this.statusType = 'info'; - + this.tracks = this.client.getTrackList(); - this.selectedTracks = []; - + this.statusMessage = `Loaded ${this.tracks.length} tracks.`; this.statusType = 'success'; } catch (error) { @@ -303,44 +387,160 @@ this.isLoading = false; } }, - - async downloadTracks() { - if (this.selectedTracks.length === 0) return; - + + async downloadTrack(trackIndex, saveToFile) { try { this.isDownloading = true; this.downloadProgress = 0; - this.statusMessage = 'Downloading selected tracks...'; + this.statusMessage = 'Downloading track...'; this.statusType = 'info'; - - this.client.selectTracks(this.selectedTracks); - - this.trackPoints = await this.client.downloadSelectedTracks((current, total) => { + + // Reset flight info before downloading + this.flightInfo = null; + + // Download the single track + this.trackPoints = await this.client.downloadStrack(trackIndex, (current, total) => { this.downloadProgress = Math.floor((current / total) * 100); return true; // Continue download }); - + + // Get flight info if available + this.flightInfo = this.client.getFlightInfo(); + this.statusMessage = `Downloaded ${this.trackPoints.length} points successfully!`; this.statusType = 'success'; + + if (saveToFile) { + // Save to file immediately + this.saveTrackToFile(); + } else { + // Initialize and update the map with track points + this.$nextTick(() => { + this.initMap(); + this.displayTrackOnMap(); + }); + } } catch (error) { console.error('Download error:', error); - this.statusMessage = 'Error downloading tracks: ' + error.message; + this.statusMessage = 'Error downloading track: ' + error.message; this.statusType = 'error'; } finally { this.isDownloading = false; } }, - + + initMap() { + // If map already exists, remove it and create a new one + if (this.map) { + this.map.remove(); + } + + // Create a new map instance + this.map = L.map(this.$refs.mapContainer).setView([0, 0], 2); + + // Add OpenStreetMap tile layer + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.map); + }, + + displayTrackOnMap() { + if (!this.map || this.trackPoints.length === 0) return; + + // Create an array of [lat, lng] points for the track + const trackLatLngs = this.trackPoints.map(point => [point.lat, point.lon]); + + // Create a polyline from the track points + const trackLine = L.polyline(trackLatLngs, { + color: 'blue', + weight: 3, + opacity: 0.7 + }).addTo(this.map); + + // Add markers for start and end points + const startPoint = this.trackPoints[0]; + const endPoint = this.trackPoints[this.trackPoints.length - 1]; + + L.marker([startPoint.lat, startPoint.lon]) + .bindPopup('Start: ' + this.formatDate(new Date(startPoint.time * 1000))) + .addTo(this.map); + + L.marker([endPoint.lat, endPoint.lon]) + .bindPopup('End: ' + this.formatDate(new Date(endPoint.time * 1000))) + .addTo(this.map); + + // Fit the map to the track bounds + this.map.fitBounds(trackLine.getBounds()); + }, + formatDate(date) { return new Date(date).toLocaleString(); }, - + formatDuration(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; - + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }, + + saveTrackToFile() { + if (this.trackPoints.length === 0) { + this.statusMessage = 'No track data to save.'; + this.statusType = 'error'; + return; + } + + try { + // Create GPX format + let gpx = '\n'; + gpx += '\n'; + gpx += ' \n'; + gpx += ` Track from ${this.deviceInfo.name}\n`; + gpx += ` \n`; + gpx += ' \n'; + gpx += ' \n'; + gpx += ' Flymaster Track\n'; + gpx += ' \n'; + + // Add track points + this.trackPoints.forEach(point => { + const time = new Date(point.time * 1000).toISOString(); + gpx += ` \n`; + gpx += ` ${point.gpsalt}\n`; + gpx += ` \n`; + gpx += ' \n'; + }); + + gpx += ' \n'; + gpx += ' \n'; + gpx += ''; + + // Create a blob and download link + const blob = new Blob([gpx], { type: 'application/gpx+xml' }); + const url = URL.createObjectURL(blob); + + // Create a temporary link element and trigger download + const a = document.createElement('a'); + a.href = url; + a.download = `flymaster_track_${new Date().toISOString().slice(0, 10)}.gpx`; + document.body.appendChild(a); + a.click(); + + // Clean up + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + + this.statusMessage = 'Track saved successfully!'; + this.statusType = 'success'; + } catch (error) { + console.error('Error saving track:', error); + this.statusMessage = 'Error saving track: ' + error.message; + this.statusType = 'error'; + } } } });