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. 642
      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,261 +98,201 @@ class FlymasterClient {
/** /**
* Initialize the GPS device * Initialize the GPS device
* This matches the C++ implementation in FlymasterGps::init_gps()
*/ */
async initGps() { async initGps() {
// Get device information try {
const result = await this.sendCommand("PFMSNP"); // Get device information
this.gpsname = result[0]; const result = await this.sendCommand("PFMSNP");
this.gpsname = result[0];
// Compute unit id as a XOR of the result
this.gpsunitid = 0; // Compute unit id as a XOR of the result (matching C++ implementation)
for (let i = 0; i < result.length; i++) { this.gpsunitid = 0;
for (let j = 0; j < result[i].length; j++) { for (let i = 0; i < result.length; i++) {
this.gpsunitid ^= result[i].charCodeAt(j) << ((j % 4) * 8); for (let j = 0; j < result[i].length; j++) {
this.gpsunitid ^= result[i].charCodeAt(j) << ((j % 4) * 8);
}
} }
}
// Download track list // Download track list
await this.sendSmplCommand("PFMDNL", ["LST"]); await this.sendSmplCommand("PFMDNL", ["LST"]);
this.saved_tracks = []; this.saved_tracks = [];
let totaltracks = 0; let totaltracks = 0;
do { do {
const trackres = await this.receiveData("PFMLST"); const trackres = await this.receiveData("PFMLST");
totaltracks = parseInt(trackres[0]); totaltracks = parseInt(trackres[0]);
const date = trackres[2]; const date = trackres[2];
const start = trackres[3]; const start = trackres[3];
const duration = trackres[4]; const duration = trackres[4];
// Parse date and time // Parse date and time
const [day, month, year] = date.split('.').map(n => parseInt(n)); const [day, month, year] = date.split('.').map(n => parseInt(n));
const [hour, minute, second] = start.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) // Create date object (note: month is 0-indexed in JS Date)
const startDate = new Date(2000 + year, month - 1, day, hour, minute, second); const startDate = new Date(2000 + year, month - 1, day, hour, minute, second);
// Parse duration // Parse duration
const [dHour, dMinute, dSecond] = duration.split(':').map(n => parseInt(n)); const [dHour, dMinute, dSecond] = duration.split(':').map(n => parseInt(n));
// Calculate end date // Calculate end date
const endDate = new Date(startDate.getTime() + const endDate = new Date(startDate.getTime() +
(dHour * 3600 + dMinute * 60 + dSecond) * 1000); (dHour * 3600 + dMinute * 60 + dSecond) * 1000);
this.saved_tracks.push({ this.saved_tracks.push({
startDate: startDate, startDate: startDate,
endDate: endDate endDate: endDate
}); });
} while (this.saved_tracks.length < totaltracks); } while (this.saved_tracks.length < totaltracks);
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 * Read a packet from the device
* This matches the C++ implementation in FlymasterGps::read_packet()
*/ */
genCommand(command, parameters = []) { async readPacket() {
let data = '$' + command + ','; try {
let cksum = 0; // Read initial packet data (at least header)
const { value: initialBytes, done: initialDone } = await this.reader.read();
// Calculate checksum for command if (initialDone || !initialBytes || initialBytes.length < 3) { // At minimum: 2 bytes ID + 1 byte length
for (let i = 0; i < command.length; i++) { throw new Error("Failed to read packet header");
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 += ','; console.debug("Received initial bytes:", this.bytesToHexString(initialBytes));
cksum ^= ','.charCodeAt(0);
}
// Add checksum as hexadecimal // Extract packet ID (first 2 bytes)
data += '*' + cksum.toString(16).padStart(2, '0').toUpperCase() + '\r\n'; const packetId = initialBytes[0] + (initialBytes[1] << 8);
return data;
}
/**
* Send a command and get response
*/
async sendCommand(command, parameters = []) {
await this.sendSmplCommand(command, parameters);
return await this.receiveData(command);
}
/** if (packetId === this.PACKET_END_MARKER) {
* Send a command without waiting for response return { end: true };
*/ }
async sendSmplCommand(command, parameters = []) {
const cmdStr = this.genCommand(command, parameters);
const encoder = new TextEncoder();
const data = encoder.encode(cmdStr);
await this.writer.write(data);
}
/** // Extract length (3rd byte)
* Read data from the device until a valid response is received const length = initialBytes[2];
*/
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(); // Calculate total expected packet size: 3 bytes header + data length + 1 byte checksum
const totalExpectedSize = 3 + length + 1;
while (Date.now() - startTime < this.DOWN_TIMEOUT) { // Create a buffer for the complete packet
const { value, done } = await this.reader.read(); let packetBytes = new Uint8Array(totalExpectedSize);
if (done) break;
for (let i = 0; i < value.length; i++) { // Copy initial bytes to the packet buffer
const ch = value[i]; let bytesReceived = Math.min(initialBytes.length, totalExpectedSize);
received++; packetBytes.set(initialBytes.slice(0, bytesReceived), 0);
// Ignore XON, XOFF // Continue reading until we have the complete packet
if (ch === this.XON || ch === this.XOFF) continue; while (bytesReceived < totalExpectedSize) {
const { value: moreBytes, done: moreDone } = await this.reader.read();
if (moreDone) {
throw new Error("Connection closed while reading packet data");
}
if (ch === 0x24) { // '$' if (!moreBytes || moreBytes.length === 0) {
incmd = true;
cksum = 0;
param = "";
recv_cmd = "";
result = [];
continue; continue;
} }
if (!incmd) continue; console.debug("Received more bytes:", this.bytesToHexString(moreBytes));
if (ch !== 0x2A) // '*' // Calculate how many more bytes we need and how many we can copy
cksum ^= ch; const bytesNeeded = totalExpectedSize - bytesReceived;
const bytesToCopy = Math.min(moreBytes.length, bytesNeeded);
if (ch === 0x2C || ch === 0x2A) { // ',' or '*' // Copy the bytes to the packet buffer
if (param.length) { packetBytes.set(moreBytes.slice(0, bytesToCopy), bytesReceived);
if (!recv_cmd.length) bytesReceived += bytesToCopy;
recv_cmd = param; }
else
result.push(param);
}
param = "";
if (ch === 0x2A) { // '*' console.debug("Complete packet assembled:", this.bytesToHexString(packetBytes));
// 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 // Extract data (next 'length' bytes after header)
i += 2; const data = new Uint8Array(length);
for (let i = 0; i < length; i++) {
data[i] = packetBytes[3 + i];
}
if (cksum_r === cksum && recv_cmd === command) // Extract checksum (last byte after data)
return result; const cksum = packetBytes[3 + length];
}
continue;
}
param += String.fromCharCode(ch); // Compute checksum
let c_cksum = length;
for (let i = 0; i < data.length; i++) {
c_cksum ^= data[i];
} }
}
throw new Error("Timeout waiting for response"); 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;
}
} }
/** /**
* Read a binary packet from the device * Download a single track
* This matches the C++ implementation in FlymasterGps::download_strack()
*/ */
async readPacket() { async downloadStrack(trackIndex, progressCallback = null) {
// Read packet ID (2 bytes) try {
const { value: idBytes } = await this.reader.read(); const track = this.saved_tracks[trackIndex];
const packetId = idBytes[0] + (idBytes[1] << 8); if (!track) {
throw new Error(`Track ${trackIndex} not found`);
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) { // Reset flight info before downloading
const { value } = await this.reader.read(); this.flightInfo = null;
const remaining = length - bytesRead;
const bytesToCopy = Math.min(remaining, value.length);
data.set(value.slice(0, bytesToCopy), bytesRead); const result = [];
bytesRead += bytesToCopy;
}
// Read checksum // Format date for command
const { value: cksumByte } = await this.reader.read(); const startDate = track.startDate;
const cksum = cksumByte[0]; 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');
// Compute checksum const timestamp = `${year}${month}${day}${hour}${minute}${second}`;
let c_cksum = length;
for (let i = 0; i < data.length; i++) {
c_cksum ^= data[i];
}
if (c_cksum !== cksum) { // Send download command
// Send negative acknowledgment await this.sendSmplCommand("PFMDNL", [timestamp]);
await this.writer.write(new Uint8Array([this.ACK_NEGATIVE]));
throw new Error("Data checksum error");
}
return { packetId, data }; let newTrack = true;
} let basePos = null;
let pktCount = 0;
let pcounter = 0;
const expCount = Math.floor((track.endDate - track.startDate) / 1000);
/** while (true) {
* 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(); 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 in downloadStrack:", error);
console.error("Error reading packet:", error); throw error;
break; }
}
/**
* 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 result; 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 {
@ -435,6 +451,138 @@ class FlymasterClient {
}; };
} }
/**
* 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 * Get list of available tracks
*/ */
@ -453,62 +601,22 @@ class FlymasterClient {
* Select tracks for download * Select tracks for download
*/ */
selectTracks(indices) { selectTracks(indices) {
this.selected_tracks = indices.sort((a, b) => b - a); // Sort in descending order this.selected_tracks = indices;
} }
/** /**
* Download all selected tracks * Download all selected tracks
*/ */
async downloadSelectedTracks(progressCallback = null) { async downloadSelectedTracks(progressCallback = null) {
let allPoints = []; return await this.downloadTracklog(progressCallback);
for (const trackIndex of this.selected_tracks) {
const points = await this.downloadTrack(trackIndex, progressCallback);
allPoints = allPoints.concat(points);
}
return allPoints;
} }
}
// 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 the flight info data
* @returns {Object|null} The flight info data or null if not available
// Get track list */
const tracks = client.getTrackList(); getFlightInfo() {
console.log("Available tracks:", tracks); return this.flightInfo;
// 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();
} }
} }

@ -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