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 4 weeks ago
parent 08ca4f386b
commit e808036092
  1. 84
      extract_the_protocol_for_communication_w.md
  2. 734
      flymaster-client.js
  3. 288
      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,35 +1,50 @@
// 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;
this.PACKET_KEY_POSITION = 0xa1a1; this.PACKET_KEY_POSITION = 0xa1a1;
this.PACKET_DELTA_POSITION = 0xa2a2; this.PACKET_DELTA_POSITION = 0xa2a2;
this.PACKET_END_MARKER = 0xa3a3; this.PACKET_END_MARKER = 0xa3a3;
// Acknowledgment bytes // Acknowledgment bytes
this.ACK_POSITIVE = 0xb1; this.ACK_POSITIVE = 0xb1;
this.ACK_NEGATIVE = 0xb3; this.ACK_NEGATIVE = 0xb3;
// Serial port and streams // Serial port and streams
this.port = null; this.port = null;
this.reader = null; this.reader = null;
this.writer = null; this.writer = null;
// Device information // Device information
this.gpsname = ""; this.gpsname = "";
this.gpsunitid = 0; this.gpsunitid = 0;
this.saved_tracks = []; this.saved_tracks = [];
this.selected_tracks = []; this.selected_tracks = [];
this.flightInfo = null;
} }
/** /**
@ -44,13 +59,13 @@ 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
this.reader = this.port.readable.getReader(); this.reader = this.port.readable.getReader();
this.writer = this.port.writable.getWriter(); this.writer = this.port.writable.getWriter();
console.log("Connected to device"); console.log("Connected to device");
return true; return true;
} catch (error) { } catch (error) {
@ -67,100 +82,402 @@ class FlymasterClient {
await this.reader.releaseLock(); await this.reader.releaseLock();
this.reader = null; this.reader = null;
} }
if (this.writer) { if (this.writer) {
await this.writer.releaseLock(); await this.writer.releaseLock();
this.writer = null; this.writer = null;
} }
if (this.port && this.port.readable) { if (this.port && this.port.readable) {
await this.port.close(); await this.port.close();
this.port = null; this.port = null;
} }
console.log("Disconnected from device"); console.log("Disconnected from device");
} }
/** /**
* 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
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"]); /**
* Download a single track
this.saved_tracks = []; * This matches the C++ implementation in FlymasterGps::download_strack()
let totaltracks = 0; */
async downloadStrack(trackIndex, progressCallback = null) {
do { try {
const trackres = await this.receiveData("PFMLST"); const track = this.saved_tracks[trackIndex];
totaltracks = parseInt(trackres[0]); if (!track) {
const date = trackres[2]; throw new Error(`Track ${trackIndex} not found`);
const start = trackres[3]; }
const duration = trackres[4];
// Reset flight info before downloading
// Parse date and time this.flightInfo = null;
const [day, month, year] = date.split('.').map(n => parseInt(n));
const [hour, minute, second] = start.split(':').map(n => parseInt(n)); const result = [];
// Create date object (note: month is 0-indexed in JS Date) // Format date for command
const startDate = new Date(2000 + year, month - 1, day, hour, minute, second); const startDate = track.startDate;
const year = startDate.getFullYear() % 100;
// Parse duration const month = (startDate.getMonth() + 1).toString().padStart(2, '0');
const [dHour, dMinute, dSecond] = duration.split(':').map(n => parseInt(n)); const day = startDate.getDate().toString().padStart(2, '0');
const hour = startDate.getHours().toString().padStart(2, '0');
// Calculate end date const minute = startDate.getMinutes().toString().padStart(2, '0');
const endDate = new Date(startDate.getTime() + const second = startDate.getSeconds().toString().padStart(2, '0');
(dHour * 3600 + dMinute * 60 + dSecond) * 1000);
const timestamp = `${year}${month}${day}${hour}${minute}${second}`;
this.saved_tracks.push({
startDate: startDate, // Send download command
endDate: endDate await this.sendSmplCommand("PFMDNL", [timestamp]);
});
let newTrack = true;
} while (this.saved_tracks.length < totaltracks); let basePos = null;
let pktCount = 0;
console.log(`Initialized GPS: ${this.gpsname}, found ${this.saved_tracks.length} tracks`); let pcounter = 0;
return this.saved_tracks; 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 * Generate NMEA command with checksum
* This matches the C++ implementation in NMEAGps::gen_command()
*/ */
genCommand(command, parameters = []) { genCommand(command, parameters = []) {
let data = '$' + command + ','; let data = '$' + command + ',';
let cksum = 0; let cksum = 0;
// Calculate checksum for command // Calculate checksum for command
for (let i = 0; i < command.length; i++) { for (let i = 0; i < command.length; i++) {
cksum ^= command.charCodeAt(i); cksum ^= command.charCodeAt(i);
} }
cksum ^= ','.charCodeAt(0); cksum ^= ','.charCodeAt(0);
// Add parameters and update checksum // Add parameters and update checksum
for (let i = 0; i < parameters.length; i++) { for (let i = 0; i < parameters.length; i++) {
data += parameters[i]; data += parameters[i];
// Update checksum with parameter // Update checksum with parameter
for (let j = 0; j < parameters[i].length; j++) { for (let j = 0; j < parameters[i].length; j++) {
cksum ^= parameters[i].charCodeAt(j); cksum ^= parameters[i].charCodeAt(j);
} }
data += ','; data += ',';
cksum ^= ','.charCodeAt(0); cksum ^= ','.charCodeAt(0);
} }
// Add checksum as hexadecimal // Add checksum as hexadecimal
data += '*' + cksum.toString(16).padStart(2, '0').toUpperCase() + '\r\n'; data += '*' + cksum.toString(16).padStart(2, '0').toUpperCase() + '\r\n';
return data; return data;
@ -168,6 +485,7 @@ class FlymasterClient {
/** /**
* Send a command and get response * Send a command and get response
* This matches the C++ implementation in NMEAGps::send_command()
*/ */
async sendCommand(command, parameters = []) { async sendCommand(command, parameters = []) {
await this.sendSmplCommand(command, parameters); await this.sendSmplCommand(command, parameters);
@ -176,16 +494,19 @@ class FlymasterClient {
/** /**
* Send a command without waiting for response * Send a command without waiting for response
* This matches the C++ implementation in NMEAGps::send_smpl_command()
*/ */
async sendSmplCommand(command, parameters = []) { async sendSmplCommand(command, parameters = []) {
const cmdStr = this.genCommand(command, parameters); const cmdStr = this.genCommand(command, parameters);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const data = encoder.encode(cmdStr); const data = encoder.encode(cmdStr);
await this.writer.write(data); await this.writer.write(data);
console.debug("Sent command", cmdStr);
} }
/** /**
* Read data from the device until a valid response is received * Read data from the device until a valid response is received
* This matches the C++ implementation in NMEAGps::receive_data()
*/ */
async receiveData(command) { async receiveData(command) {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
@ -195,20 +516,24 @@ class FlymasterClient {
let result = []; let result = [];
let incmd = false; let incmd = false;
let cksum = 0; let cksum = 0;
const startTime = Date.now(); const startTime = Date.now();
while (Date.now() - startTime < this.DOWN_TIMEOUT) { while (Date.now() - startTime < this.DOWN_TIMEOUT) {
const { value, done } = await this.reader.read(); const { value, done } = await this.reader.read();
if (done) break; if (done) {
break;
}
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
const ch = value[i]; const ch = value[i];
received++; received++;
// Ignore XON, XOFF // Ignore XON, XOFF
if (ch === this.XON || ch === this.XOFF) continue; if (ch === this.XON || ch === this.XOFF) {
continue;
}
if (ch === 0x24) { // '$' if (ch === 0x24) { // '$'
incmd = true; incmd = true;
cksum = 0; cksum = 0;
@ -217,222 +542,45 @@ class FlymasterClient {
result = []; result = [];
continue; continue;
} }
if (!incmd) continue; if (!incmd) {
continue;
if (ch !== 0x2A) // '*' }
if (ch !== 0x2A) { // Not '*'
cksum ^= ch; cksum ^= ch;
}
if (ch === 0x2C || ch === 0x2A) { // ',' or '*' if (ch === 0x2C || ch === 0x2A) { // ',' or '*'
if (param.length) { if (param.length) {
if (!recv_cmd.length) if (!recv_cmd.length) {
recv_cmd = param; recv_cmd = param;
else } else {
result.push(param); result.push(param);
}
} }
param = ""; param = "";
if (ch === 0x2A) { // '*' if (ch === 0x2A) { // '*'
// Read checksum (2 hex characters) // Read checksum (2 hex characters)
const cksum_s = String.fromCharCode(value[++i]) + String.fromCharCode(value[++i]); const cksum_s = String.fromCharCode(value[++i]) + String.fromCharCode(value[++i]);
const cksum_r = parseInt(cksum_s, 16); const cksum_r = parseInt(cksum_s, 16);
// Skip CR, LF // Skip CR, LF
i += 2; i += 2;
if (cksum_r === cksum && recv_cmd === command) if (cksum_r === cksum && recv_cmd === command) {
return result; return result;
}
} }
continue; continue;
} }
param += String.fromCharCode(ch);
}
}
throw new Error("Timeout waiting for response");
}
/** param += String.fromCharCode(ch);
* 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;
} }
} }
return result;
}
/** throw new Error(`Timeout waiting for response to ${command}`);
* 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
};
} }
/** /**
@ -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() { * Get the flight info data
const client = new FlymasterClient(); * @returns {Object|null} The flight info data or null if not available
*/
try { getFlightInfo() {
// Connect to device return this.flightInfo;
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();
} }
} }

@ -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,63 +110,86 @@
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>
<div id="app"> <div id="app">
<h1>Flymaster GPS Client</h1> <h1>Flymaster GPS Client</h1>
<div class="container"> <div class="container">
<h2>Connection</h2> <h2>Connection</h2>
<button @click="connect" :disabled="isConnected">Connect</button> <button @click="connect" :disabled="isConnected">Connect</button>
<button @click="disconnect" :disabled="!isConnected">Disconnect</button> <button @click="disconnect" :disabled="!isConnected">Disconnect</button>
<div v-if="statusMessage" :class="['status', statusType]"> <div v-if="statusMessage" :class="['status', statusType]">
{{ statusMessage }} {{ statusMessage }}
</div> </div>
<div v-if="deviceInfo"> <div v-if="deviceInfo">
<h3>Device Information</h3> <h3>Device Information</h3>
<p><strong>Name:</strong> {{ deviceInfo.name }}</p> <p><strong>Name:</strong> {{ deviceInfo.name }}</p>
<p><strong>Unit ID:</strong> {{ deviceInfo.unitId }}</p> <p><strong>Unit ID:</strong> {{ deviceInfo.unitId }}</p>
</div> </div>
</div> </div>
<div class="container" v-if="isConnected"> <div class="container" v-if="isConnected">
<h2>Tracks</h2> <h2>Tracks</h2>
<button @click="refreshTracks" :disabled="isLoading">Refresh Track List</button> <button @click="refreshTracks" :disabled="isLoading">Refresh Track List</button>
<div v-if="isLoading" class="status info"> <div v-if="isLoading" class="status info">
Loading tracks... Loading tracks...
</div> </div>
<div v-if="tracks.length > 0" class="track-list"> <div v-if="tracks.length > 0" class="track-list">
<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">
<h3>Download Progress</h3> <h3>Download Progress</h3>
<div class="progress-container"> <div class="progress-container">
@ -160,11 +199,50 @@
</div> </div>
</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,19 +298,21 @@
} }
}, },
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) {
this.isConnected = true; this.isConnected = true;
this.statusMessage = 'Connected to device successfully!'; this.statusMessage = 'Connected to device successfully!';
this.statusType = 'success'; this.statusType = 'success';
// Initialize GPS // Initialize GPS
await this.initializeGps(); await this.initializeGps();
} else { } else {
@ -241,7 +325,7 @@
this.statusType = 'error'; this.statusType = 'error';
} }
}, },
async disconnect() { async disconnect() {
try { try {
if (this.client) { if (this.client) {
@ -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);
@ -257,22 +342,22 @@
this.statusType = 'error'; this.statusType = 'error';
} }
}, },
async initializeGps() { async initializeGps() {
try { try {
this.isLoading = true; this.isLoading = true;
this.statusMessage = 'Initializing GPS...'; this.statusMessage = 'Initializing GPS...';
this.statusType = 'info'; this.statusType = 'info';
await this.client.initGps(); await this.client.initGps();
this.deviceInfo = { this.deviceInfo = {
name: this.client.gpsname, name: this.client.gpsname,
unitId: this.client.gpsunitid unitId: this.client.gpsunitid
}; };
this.refreshTracks(); this.refreshTracks();
this.statusMessage = 'GPS initialized successfully!'; this.statusMessage = 'GPS initialized successfully!';
this.statusType = 'success'; this.statusType = 'success';
} catch (error) { } catch (error) {
@ -283,16 +368,15 @@
this.isLoading = false; this.isLoading = false;
} }
}, },
async refreshTracks() { async refreshTracks() {
try { try {
this.isLoading = true; this.isLoading = true;
this.statusMessage = 'Loading tracks...'; this.statusMessage = 'Loading tracks...';
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';
} catch (error) { } catch (error) {
@ -303,44 +387,160 @@
this.isLoading = false; this.isLoading = false;
} }
}, },
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();
}, },
formatDuration(seconds) { formatDuration(seconds) {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
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