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

@ -8,6 +8,14 @@
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<!-- Flymaster Client -->
<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>
body {
font-family: Arial, sans-serif;
@ -41,6 +49,11 @@
background-color: #cccccc;
cursor: not-allowed;
}
.download-button {
background-color: #2196F3;
margin-top: 10px;
margin-bottom: 15px;
}
.status {
margin: 10px 0;
padding: 10px;
@ -73,6 +86,9 @@
th {
background-color: #f2f2f2;
}
.flight-info-table th {
width: 200px;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
@ -94,63 +110,86 @@
max-height: 300px;
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>
</head>
<body>
<div id="app">
<h1>Flymaster GPS Client</h1>
<div class="container">
<h2>Connection</h2>
<button @click="connect" :disabled="isConnected">Connect</button>
<button @click="disconnect" :disabled="!isConnected">Disconnect</button>
<div v-if="statusMessage" :class="['status', statusType]">
{{ statusMessage }}
</div>
<div v-if="deviceInfo">
<h3>Device Information</h3>
<p><strong>Name:</strong> {{ deviceInfo.name }}</p>
<p><strong>Unit ID:</strong> {{ deviceInfo.unitId }}</p>
</div>
</div>
<div class="container" v-if="isConnected">
<h2>Tracks</h2>
<button @click="refreshTracks" :disabled="isLoading">Refresh Track List</button>
<div v-if="isLoading" class="status info">
Loading tracks...
</div>
<div v-if="tracks.length > 0" class="track-list">
<table>
<thead>
<tr>
<th>Select</th>
<th>#</th>
<th>Start Date</th>
<th>End Date</th>
<th>Duration</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<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>{{ formatDate(track.startDate) }}</td>
<td>{{ formatDate(track.endDate) }}</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>
</tbody>
</table>
<button @click="downloadTracks" :disabled="selectedTracks.length === 0 || isDownloading">
Download Selected Tracks
</button>
</div>
<div v-if="isDownloading">
<h3>Download Progress</h3>
<div class="progress-container">
@ -160,11 +199,50 @@
</div>
</div>
</div>
<div class="container" v-if="trackPoints.length > 0">
<h2>Downloaded Track Data</h2>
<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">
<h3>Sample Points</h3>
<table>
@ -190,6 +268,9 @@
</tbody>
</table>
</div>
<h3>Track Map</h3>
<div id="map-container" ref="mapContainer"></div>
</div>
</div>
@ -205,9 +286,10 @@
statusType: 'info',
deviceInfo: null,
tracks: [],
selectedTracks: [],
trackPoints: [],
downloadProgress: 0
downloadProgress: 0,
map: null,
flightInfo: null
},
computed: {
samplePoints() {
@ -216,19 +298,21 @@
}
},
methods: {
async connect() {
try {
this.statusMessage = 'Connecting to device...';
this.statusType = 'info';
this.client = new FlymasterClient();
const connected = await this.client.connect();
if (connected) {
this.isConnected = true;
this.statusMessage = 'Connected to device successfully!';
this.statusType = 'success';
// Initialize GPS
await this.initializeGps();
} else {
@ -241,7 +325,7 @@
this.statusType = 'error';
}
},
async disconnect() {
try {
if (this.client) {
@ -250,6 +334,7 @@
this.statusMessage = 'Disconnected from device.';
this.statusType = 'info';
this.deviceInfo = null;
this.flightInfo = null;
}
} catch (error) {
console.error('Disconnection error:', error);
@ -257,22 +342,22 @@
this.statusType = 'error';
}
},
async initializeGps() {
try {
this.isLoading = true;
this.statusMessage = 'Initializing GPS...';
this.statusType = 'info';
await this.client.initGps();
this.deviceInfo = {
name: this.client.gpsname,
unitId: this.client.gpsunitid
};
this.refreshTracks();
this.statusMessage = 'GPS initialized successfully!';
this.statusType = 'success';
} catch (error) {
@ -283,16 +368,15 @@
this.isLoading = false;
}
},
async refreshTracks() {
try {
this.isLoading = true;
this.statusMessage = 'Loading tracks...';
this.statusType = 'info';
this.tracks = this.client.getTrackList();
this.selectedTracks = [];
this.statusMessage = `Loaded ${this.tracks.length} tracks.`;
this.statusType = 'success';
} catch (error) {
@ -303,44 +387,160 @@
this.isLoading = false;
}
},
async downloadTracks() {
if (this.selectedTracks.length === 0) return;
async downloadTrack(trackIndex, saveToFile) {
try {
this.isDownloading = true;
this.downloadProgress = 0;
this.statusMessage = 'Downloading selected tracks...';
this.statusMessage = 'Downloading track...';
this.statusType = 'info';
this.client.selectTracks(this.selectedTracks);
this.trackPoints = await this.client.downloadSelectedTracks((current, total) => {
// Reset flight info before downloading
this.flightInfo = null;
// Download the single track
this.trackPoints = await this.client.downloadStrack(trackIndex, (current, total) => {
this.downloadProgress = Math.floor((current / total) * 100);
return true; // Continue download
});
// Get flight info if available
this.flightInfo = this.client.getFlightInfo();
this.statusMessage = `Downloaded ${this.trackPoints.length} points successfully!`;
this.statusType = 'success';
if (saveToFile) {
// Save to file immediately
this.saveTrackToFile();
} else {
// Initialize and update the map with track points
this.$nextTick(() => {
this.initMap();
this.displayTrackOnMap();
});
}
} catch (error) {
console.error('Download error:', error);
this.statusMessage = 'Error downloading tracks: ' + error.message;
this.statusMessage = 'Error downloading track: ' + error.message;
this.statusType = 'error';
} finally {
this.isDownloading = false;
}
},
initMap() {
// If map already exists, remove it and create a new one
if (this.map) {
this.map.remove();
}
// Create a new map instance
this.map = L.map(this.$refs.mapContainer).setView([0, 0], 2);
// Add OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&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) {
return new Date(date).toLocaleString();
},
formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
},
saveTrackToFile() {
if (this.trackPoints.length === 0) {
this.statusMessage = 'No track data to save.';
this.statusType = 'error';
return;
}
try {
// Create GPX format
let gpx = '<?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