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.
main
Arne Schauf 10 months ago
parent 08ca4f386b
commit e808036092
  1. 84
      extract_the_protocol_for_communication_w.md
  2. 502
      flymaster-client.js
  3. 232
      flymaster-ui.html

@ -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:
```
$<command>,<param1>,<param2>,...*<checksum>\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,<timestamp>` 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.

@ -1,14 +1,28 @@
// flymaster-client.js // flymaster-client.js
// WebSerial implementation of the Flymaster GPS protocol // 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() { constructor() {
// Constants // Constants
this.BAUDRATE = 57600; this.BAUDRATE = 57600;
this.XON = 0x11; this.XON = 0x11;
this.XOFF = 0x13; this.XOFF = 0x13;
this.MAX_LINE = 90; 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 // Packet IDs
this.PACKET_FLIGHT_INFO = 0xa0a0; this.PACKET_FLIGHT_INFO = 0xa0a0;
@ -30,6 +44,7 @@ class FlymasterClient {
this.gpsunitid = 0; this.gpsunitid = 0;
this.saved_tracks = []; this.saved_tracks = [];
this.selected_tracks = []; this.selected_tracks = [];
this.flightInfo = null;
} }
/** /**
@ -44,7 +59,7 @@ class FlymasterClient {
dataBits: 8, dataBits: 8,
stopBits: 1, stopBits: 1,
parity: "none", parity: "none",
flowControl: "none" // We'll handle XON/XOFF in software flowControl: "none"
}); });
// Get reader and writer // Get reader and writer
@ -83,13 +98,15 @@ class FlymasterClient {
/** /**
* Initialize the GPS device * Initialize the GPS device
* This matches the C++ implementation in FlymasterGps::init_gps()
*/ */
async initGps() { async initGps() {
try {
// Get device information // Get device information
const result = await this.sendCommand("PFMSNP"); const result = await this.sendCommand("PFMSNP");
this.gpsname = result[0]; this.gpsname = result[0];
// Compute unit id as a XOR of the result // Compute unit id as a XOR of the result (matching C++ implementation)
this.gpsunitid = 0; this.gpsunitid = 0;
for (let i = 0; i < result.length; i++) { for (let i = 0; i < result.length; i++) {
for (let j = 0; j < result[i].length; j++) { for (let j = 0; j < result[i].length; j++) {
@ -133,158 +150,78 @@ class FlymasterClient {
console.log(`Initialized GPS: ${this.gpsname}, found ${this.saved_tracks.length} tracks`); console.log(`Initialized GPS: ${this.gpsname}, found ${this.saved_tracks.length} tracks`);
return this.saved_tracks; return this.saved_tracks;
} catch (error) {
console.error("Error in initGps:", error);
throw error;
} }
/**
* Generate NMEA command with checksum
*/
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
*/
async sendCommand(command, parameters = []) {
await this.sendSmplCommand(command, parameters);
return await this.receiveData(command);
} }
/** /**
* Send a command without waiting for response * Read a packet from the device
* This matches the C++ implementation in FlymasterGps::read_packet()
*/ */
async sendSmplCommand(command, parameters = []) { async readPacket() {
const cmdStr = this.genCommand(command, parameters); try {
const encoder = new TextEncoder(); // Read initial packet data (at least header)
const data = encoder.encode(cmdStr); const { value: initialBytes, done: initialDone } = await this.reader.read();
await this.writer.write(data); 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));
* Read data from the device until a valid response is received
*/
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) { // Extract packet ID (first 2 bytes)
const { value, done } = await this.reader.read(); const packetId = initialBytes[0] + (initialBytes[1] << 8);
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) { // '$' if (packetId === this.PACKET_END_MARKER) {
incmd = true; return { end: true };
cksum = 0;
param = "";
recv_cmd = "";
result = [];
continue;
} }
if (!incmd) continue; // Extract length (3rd byte)
const length = initialBytes[2];
if (ch !== 0x2A) // '*' // Calculate total expected packet size: 3 bytes header + data length + 1 byte checksum
cksum ^= ch; const totalExpectedSize = 3 + length + 1;
if (ch === 0x2C || ch === 0x2A) { // ',' or '*' // Create a buffer for the complete packet
if (param.length) { let packetBytes = new Uint8Array(totalExpectedSize);
if (!recv_cmd.length)
recv_cmd = param;
else
result.push(param);
}
param = "";
if (ch === 0x2A) { // '*' // Copy initial bytes to the packet buffer
// Read checksum (2 hex characters) let bytesReceived = Math.min(initialBytes.length, totalExpectedSize);
const cksum_s = String.fromCharCode(value[++i]) + String.fromCharCode(value[++i]); packetBytes.set(initialBytes.slice(0, bytesReceived), 0);
const cksum_r = parseInt(cksum_s, 16);
// Skip CR, LF // Continue reading until we have the complete packet
i += 2; while (bytesReceived < totalExpectedSize) {
const { value: moreBytes, done: moreDone } = await this.reader.read();
if (cksum_r === cksum && recv_cmd === command) if (moreDone) {
return result; throw new Error("Connection closed while reading packet data");
}
continue;
} }
param += String.fromCharCode(ch); if (!moreBytes || moreBytes.length === 0) {
} continue;
} }
throw new Error("Timeout waiting for response"); console.debug("Received more bytes:", this.bytesToHexString(moreBytes));
}
/** // Calculate how many more bytes we need and how many we can copy
* Read a binary packet from the device const bytesNeeded = totalExpectedSize - bytesReceived;
*/ const bytesToCopy = Math.min(moreBytes.length, bytesNeeded);
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) { // Copy the bytes to the packet buffer
return { end: true }; packetBytes.set(moreBytes.slice(0, bytesToCopy), bytesReceived);
bytesReceived += bytesToCopy;
} }
// Read length (1 byte) console.debug("Complete packet assembled:", this.bytesToHexString(packetBytes));
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); // Extract data (next 'length' bytes after header)
bytesRead += bytesToCopy; const data = new Uint8Array(length);
for (let i = 0; i < length; i++) {
data[i] = packetBytes[3 + i];
} }
// Read checksum // Extract checksum (last byte after data)
const { value: cksumByte } = await this.reader.read(); const cksum = packetBytes[3 + length];
const cksum = cksumByte[0];
// Compute checksum // Compute checksum
let c_cksum = length; let c_cksum = length;
@ -297,15 +234,31 @@ class FlymasterClient {
await this.writer.write(new Uint8Array([this.ACK_NEGATIVE])); await this.writer.write(new Uint8Array([this.ACK_NEGATIVE]));
throw new Error("Data checksum error"); throw new Error("Data checksum error");
} }
else {
await this.writer.write(new Uint8Array([this.ACK_POSITIVE]));
}
return { packetId, data }; return { packetId, data };
} catch (error) {
console.error("Error in readPacket:", error);
throw error;
}
} }
/** /**
* Download a specific track * Download a single track
* This matches the C++ implementation in FlymasterGps::download_strack()
*/ */
async downloadTrack(trackIndex, progressCallback = null) { async downloadStrack(trackIndex, progressCallback = null) {
try {
const track = this.saved_tracks[trackIndex]; 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 = []; const result = [];
// Format date for command // Format date for command
@ -329,15 +282,17 @@ class FlymasterClient {
const expCount = Math.floor((track.endDate - track.startDate) / 1000); const expCount = Math.floor((track.endDate - track.startDate) / 1000);
while (true) { while (true) {
try {
const packet = await this.readPacket(); const packet = await this.readPacket();
// Check if end of transmission // Check if end of transmission
if (packet.end) break; if (packet.end) {
break;
}
if (packet.packetId === this.PACKET_FLIGHT_INFO) { if (packet.packetId === this.PACKET_FLIGHT_INFO) {
// Flight information packet // Flight information packet
// We could parse this, but for now we'll just acknowledge it this.flightInfo = this.parseFlightInfo(packet.data);
console.debug("Received flight info:", this.flightInfo);
} }
else if (packet.packetId === this.PACKET_KEY_POSITION) { else if (packet.packetId === this.PACKET_KEY_POSITION) {
// Key position packet // Key position packet
@ -372,31 +327,84 @@ class FlymasterClient {
result.push(this.makePoint(basePos, false)); result.push(this.makePoint(basePos, false));
} }
} }
}
// Send positive acknowledgment return result;
await this.writer.write(new Uint8Array([this.ACK_POSITIVE]));
} catch (error) { } catch (error) {
console.error("Error reading packet:", error); console.error("Error in downloadStrack:", error);
break; 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; 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 * Parse a key position packet
*/ */
parseKeyPosition(data) { parseKeyPosition(data) {
if (!data || data.length < 17) {
throw new Error("Invalid key position data");
}
const view = new DataView(data.buffer); const view = new DataView(data.buffer);
return { return {
fix: data[16],
latitude: view.getInt32(0, true), // little-endian latitude: view.getInt32(0, true), // little-endian
longitude: view.getInt32(4, true), // little-endian longitude: view.getInt32(4, true), // little-endian
gpsaltitude: view.getInt16(8, true), // little-endian gpsaltitude: view.getInt16(8, true), // little-endian
baro: view.getInt16(10, true), // little-endian baro: view.getInt16(10, true), // little-endian
time: view.getUint32(12, true), // little-endian time: view.getUint32(12, true) // little-endian
fix: data[16]
}; };
} }
@ -404,18 +412,25 @@ class FlymasterClient {
* Parse delta positions from a packet * Parse delta positions from a packet
*/ */
parseDeltaPositions(data) { parseDeltaPositions(data) {
if (!data) {
throw new Error("Invalid delta position data");
}
const deltas = []; const deltas = [];
const DELTA_SIZE = 9; // Size of each delta structure 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) { for (let i = 0; i + DELTA_SIZE <= data.length; i += DELTA_SIZE) {
deltas.push({ const delta = {
fix: data[i], fix: data[i],
latoff: new Int16Array(data.buffer.slice(i + 1, i + 3))[0], latoff: view.getInt8(i + 1),
lonoff: new Int16Array(data.buffer.slice(i + 3, i + 5))[0], lonoff: view.getInt8(i + 2),
gpsaltoff: data[i + 5], gpsaltoff: view.getInt8(i + 3),
baroff: data[i + 6], baroff: view.getInt8(i + 4),
timeoff: new Uint16Array(data.buffer.slice(i + 7, i + 9))[0] timeoff: data[i + 5]
}); };
deltas.push(delta);
} }
return deltas; return deltas;
@ -423,6 +438,7 @@ class FlymasterClient {
/** /**
* Convert a position to a trackpoint * Convert a position to a trackpoint
* This matches the C++ implementation in make_point()
*/ */
makePoint(pos, newTrack) { makePoint(pos, newTrack) {
return { return {
@ -436,79 +452,171 @@ class FlymasterClient {
} }
/** /**
* Get list of available tracks * Generate NMEA command with checksum
* This matches the C++ implementation in NMEAGps::gen_command()
*/ */
getTrackList() { genCommand(command, parameters = []) {
return this.saved_tracks.map((track, index) => { let data = '$' + command + ',';
return { let cksum = 0;
index: index,
startDate: track.startDate, // Calculate checksum for command
endDate: track.endDate, for (let i = 0; i < command.length; i++) {
duration: Math.floor((track.endDate - track.startDate) / 1000) 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;
} }
/** /**
* Select tracks for download * Send a command and get response
* This matches the C++ implementation in NMEAGps::send_command()
*/ */
selectTracks(indices) { async sendCommand(command, parameters = []) {
this.selected_tracks = indices.sort((a, b) => b - a); // Sort in descending order await this.sendSmplCommand(command, parameters);
return await this.receiveData(command);
} }
/** /**
* Download all selected tracks * Send a command without waiting for response
* This matches the C++ implementation in NMEAGps::send_smpl_command()
*/ */
async downloadSelectedTracks(progressCallback = null) { async sendSmplCommand(command, parameters = []) {
let allPoints = []; 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();
for (const trackIndex of this.selected_tracks) { while (Date.now() - startTime < this.DOWN_TIMEOUT) {
const points = await this.downloadTrack(trackIndex, progressCallback); const { value, done } = await this.reader.read();
allPoints = allPoints.concat(points); if (done) {
break;
} }
return allPoints; 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;
} }
// Example usage: if (!incmd) {
async function connectToFlymaster() { continue;
const client = new FlymasterClient(); }
try { if (ch !== 0x2A) { // Not '*'
// Connect to device cksum ^= ch;
const connected = await client.connect();
if (!connected) {
console.error("Failed to connect to device");
return;
} }
// Initialize GPS if (ch === 0x2C || ch === 0x2A) { // ',' or '*'
await client.initGps(); if (param.length) {
if (!recv_cmd.length) {
recv_cmd = param;
} else {
result.push(param);
}
}
param = "";
// Get track list if (ch === 0x2A) { // '*'
const tracks = client.getTrackList(); // Read checksum (2 hex characters)
console.log("Available tracks:", tracks); const cksum_s = String.fromCharCode(value[++i]) + String.fromCharCode(value[++i]);
const cksum_r = parseInt(cksum_s, 16);
// Select tracks to download (e.g., the most recent one) // Skip CR, LF
client.selectTracks([0]); i += 2;
// Download selected tracks with progress callback if (cksum_r === cksum && recv_cmd === command) {
const points = await client.downloadSelectedTracks((current, total) => { return result;
const percent = Math.floor((current / total) * 100); }
console.log(`Download progress: ${percent}%`); }
return true; // Return false to cancel download 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)
};
}); });
}
console.log(`Downloaded ${points.length} points`); /**
* Select tracks for download
*/
selectTracks(indices) {
this.selected_tracks = indices;
}
// Disconnect /**
await client.disconnect(); * Download all selected tracks
*/
async downloadSelectedTracks(progressCallback = null) {
return await this.downloadTracklog(progressCallback);
}
return points; /**
} catch (error) { * Get the flight info data
console.error("Error:", error); * @returns {Object|null} The flight info data or null if not available
await client.disconnect(); */
getFlightInfo() {
return this.flightInfo;
} }
} }

@ -8,6 +8,14 @@
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<!-- Flymaster Client --> <!-- Flymaster Client -->
<script src="flymaster-client.js"></script> <script src="flymaster-client.js"></script>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<!-- Leaflet JavaScript -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@ -41,6 +49,11 @@
background-color: #cccccc; background-color: #cccccc;
cursor: not-allowed; cursor: not-allowed;
} }
.download-button {
background-color: #2196F3;
margin-top: 10px;
margin-bottom: 15px;
}
.status { .status {
margin: 10px 0; margin: 10px 0;
padding: 10px; padding: 10px;
@ -73,6 +86,9 @@
th { th {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
.flight-info-table th {
width: 200px;
}
tr:nth-child(even) { tr:nth-child(even) {
background-color: #f9f9f9; background-color: #f9f9f9;
} }
@ -94,6 +110,26 @@
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
} }
#map-container {
height: 400px;
width: 100%;
margin-top: 15px;
}
.action-button {
margin: 2px;
padding: 5px 10px;
font-size: 12px;
}
.view-button {
background-color: #2196F3;
}
.save-button {
background-color: #4CAF50;
}
.not-set {
color: #999;
font-style: italic;
}
</style> </style>
</head> </head>
<body> <body>
@ -128,27 +164,30 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Select</th>
<th>#</th> <th>#</th>
<th>Start Date</th> <th>Start Date</th>
<th>End Date</th> <th>End Date</th>
<th>Duration</th> <th>Duration</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="track in tracks" :key="track.index"> <tr v-for="track in tracks" :key="track.index">
<td><input type="checkbox" v-model="selectedTracks" :value="track.index"></td>
<td>{{ track.index + 1 }}</td> <td>{{ track.index + 1 }}</td>
<td>{{ formatDate(track.startDate) }}</td> <td>{{ formatDate(track.startDate) }}</td>
<td>{{ formatDate(track.endDate) }}</td> <td>{{ formatDate(track.endDate) }}</td>
<td>{{ formatDuration(track.duration) }}</td> <td>{{ formatDuration(track.duration) }}</td>
<td>
<button @click="downloadTrack(track.index, false)" :disabled="isDownloading" class="action-button view-button">
View
</button>
<button @click="downloadTrack(track.index, true)" :disabled="isDownloading" class="action-button save-button">
Save
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<button @click="downloadTracks" :disabled="selectedTracks.length === 0 || isDownloading">
Download Selected Tracks
</button>
</div> </div>
<div v-if="isDownloading"> <div v-if="isDownloading">
@ -161,10 +200,49 @@
</div> </div>
</div> </div>
<div class="container" v-if="trackPoints.length > 0"> <div class="container" v-if="trackPoints.length > 0">
<h2>Downloaded Track Data</h2> <h2>Downloaded Track Data</h2>
<p>Total points: {{ trackPoints.length }}</p> <p>Total points: {{ trackPoints.length }}</p>
<button @click="saveTrackToFile" class="download-button">Save Track to File</button>
<div v-if="flightInfo">
<h3>Flight Information</h3>
<table class="flight-info-table">
<tbody>
<tr>
<th>Software Version</th>
<td>{{ flightInfo.sw_version }}</td>
</tr>
<tr>
<th>Hardware Version</th>
<td>{{ flightInfo.hw_version }}</td>
</tr>
<tr>
<th>Serial Number</th>
<td>{{ flightInfo.serial }}</td>
</tr>
<tr>
<th>Competition Number</th>
<td :class="{ 'not-set': !flightInfo.compnum }">{{ flightInfo.compnum || 'Not set' }}</td>
</tr>
<tr>
<th>Pilot Name</th>
<td :class="{ 'not-set': !flightInfo.pilotname }">{{ flightInfo.pilotname || 'Not set' }}</td>
</tr>
<tr>
<th>Glider Brand</th>
<td :class="{ 'not-set': !flightInfo.gliderbrand }">{{ flightInfo.gliderbrand || 'Not set' }}</td>
</tr>
<tr>
<th>Glider Model</th>
<td :class="{ 'not-set': !flightInfo.glidermodel }">{{ flightInfo.glidermodel || 'Not set' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="trackPoints.length > 0"> <div v-if="trackPoints.length > 0">
<h3>Sample Points</h3> <h3>Sample Points</h3>
<table> <table>
@ -190,6 +268,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<h3>Track Map</h3>
<div id="map-container" ref="mapContainer"></div>
</div> </div>
</div> </div>
@ -205,9 +286,10 @@
statusType: 'info', statusType: 'info',
deviceInfo: null, deviceInfo: null,
tracks: [], tracks: [],
selectedTracks: [],
trackPoints: [], trackPoints: [],
downloadProgress: 0 downloadProgress: 0,
map: null,
flightInfo: null
}, },
computed: { computed: {
samplePoints() { samplePoints() {
@ -216,12 +298,14 @@
} }
}, },
methods: { methods: {
async connect() { async connect() {
try { try {
this.statusMessage = 'Connecting to device...'; this.statusMessage = 'Connecting to device...';
this.statusType = 'info'; this.statusType = 'info';
this.client = new FlymasterClient(); this.client = new FlymasterClient();
const connected = await this.client.connect(); const connected = await this.client.connect();
if (connected) { if (connected) {
@ -250,6 +334,7 @@
this.statusMessage = 'Disconnected from device.'; this.statusMessage = 'Disconnected from device.';
this.statusType = 'info'; this.statusType = 'info';
this.deviceInfo = null; this.deviceInfo = null;
this.flightInfo = null;
} }
} catch (error) { } catch (error) {
console.error('Disconnection error:', error); console.error('Disconnection error:', error);
@ -291,7 +376,6 @@
this.statusType = 'info'; this.statusType = 'info';
this.tracks = this.client.getTrackList(); this.tracks = this.client.getTrackList();
this.selectedTracks = [];
this.statusMessage = `Loaded ${this.tracks.length} tracks.`; this.statusMessage = `Loaded ${this.tracks.length} tracks.`;
this.statusType = 'success'; this.statusType = 'success';
@ -304,33 +388,91 @@
} }
}, },
async downloadTracks() { async downloadTrack(trackIndex, saveToFile) {
if (this.selectedTracks.length === 0) return;
try { try {
this.isDownloading = true; this.isDownloading = true;
this.downloadProgress = 0; this.downloadProgress = 0;
this.statusMessage = 'Downloading selected tracks...'; this.statusMessage = 'Downloading track...';
this.statusType = 'info'; this.statusType = 'info';
this.client.selectTracks(this.selectedTracks); // Reset flight info before downloading
this.flightInfo = null;
this.trackPoints = await this.client.downloadSelectedTracks((current, total) => { // Download the single track
this.trackPoints = await this.client.downloadStrack(trackIndex, (current, total) => {
this.downloadProgress = Math.floor((current / total) * 100); this.downloadProgress = Math.floor((current / total) * 100);
return true; // Continue download return true; // Continue download
}); });
// Get flight info if available
this.flightInfo = this.client.getFlightInfo();
this.statusMessage = `Downloaded ${this.trackPoints.length} points successfully!`; this.statusMessage = `Downloaded ${this.trackPoints.length} points successfully!`;
this.statusType = 'success'; 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) { } catch (error) {
console.error('Download error:', error); console.error('Download error:', error);
this.statusMessage = 'Error downloading tracks: ' + error.message; this.statusMessage = 'Error downloading track: ' + error.message;
this.statusType = 'error'; this.statusType = 'error';
} finally { } finally {
this.isDownloading = false; 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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) { formatDate(date) {
return new Date(date).toLocaleString(); return new Date(date).toLocaleString();
}, },
@ -341,6 +483,64 @@
const secs = seconds % 60; const secs = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; 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 = '<?xml version="1.0" encoding="UTF-8"?>\n';
gpx += '<gpx version="1.1" creator="Flymaster GPS Client" xmlns="http://www.topografix.com/GPX/1/1">\n';
gpx += ' <metadata>\n';
gpx += ` <name>Track from ${this.deviceInfo.name}</name>\n`;
gpx += ` <time>${new Date().toISOString()}</time>\n`;
gpx += ' </metadata>\n';
gpx += ' <trk>\n';
gpx += ' <name>Flymaster Track</name>\n';
gpx += ' <trkseg>\n';
// Add track points
this.trackPoints.forEach(point => {
const time = new Date(point.time * 1000).toISOString();
gpx += ` <trkpt lat="${point.lat}" lon="${point.lon}">\n`;
gpx += ` <ele>${point.gpsalt}</ele>\n`;
gpx += ` <time>${time}</time>\n`;
gpx += ' </trkpt>\n';
});
gpx += ' </trkseg>\n';
gpx += ' </trk>\n';
gpx += '</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';
}
} }
} }
}); });

Loading…
Cancel
Save