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