You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
flyweb/flymaster-client.js

628 lines
18 KiB

// flymaster-client.js
// WebSerial implementation of the Flymaster GPS protocol
// 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 (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;
}
/**
* Request port access and open connection
*/
async connect() {
try {
// Request a port and open it
this.port = await navigator.serial.requestPort();
await this.port.open({
baudRate: this.BAUDRATE,
dataBits: 8,
stopBits: 1,
parity: "none",
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) {
console.error("Connection failed:", error);
return false;
}
}
/**
* Close the connection
*/
async disconnect() {
if (this.reader) {
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() {
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 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: view.getInt8(0),
latitude: view.getInt32(1, true), // little-endian
longitude: view.getInt32(5, true), // little-endian
gpsaltitude: view.getInt16(9, true), // little-endian
baro: view.getInt16(11, true), // little-endian
time: view.getUint32(13, 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: view.getInt8(i),
latoff: view.getInt8(i + 1),
lonoff: view.getInt8(i + 2),
gpsaltoff: view.getInt8(i + 3),
baroff: view.getInt8(i + 4),
timeoff: view.getUint8(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;
}
/**
* Send a command and get response
* This matches the C++ implementation in NMEAGps::send_command()
*/
async sendCommand(command, parameters = []) {
await this.sendSmplCommand(command, parameters);
return await this.receiveData(command);
}
/**
* Send a command without waiting for response
* This matches the C++ implementation in NMEAGps::send_smpl_command()
*/
async sendSmplCommand(command, parameters = []) {
const cmdStr = this.genCommand(command, parameters);
const encoder = new TextEncoder();
const data = encoder.encode(cmdStr);
await this.writer.write(data);
console.debug("Sent command", cmdStr);
}
/**
* Read data from the device until a valid response is received
* This matches the C++ implementation in NMEAGps::receive_data()
*/
async receiveData(command) {
const decoder = new TextDecoder();
let received = 0;
let recv_cmd = "";
let param = "";
let result = [];
let incmd = false;
let cksum = 0;
const startTime = Date.now();
while (Date.now() - startTime < this.DOWN_TIMEOUT) {
const { value, done } = await this.reader.read();
if (done) {
break;
}
for (let i = 0; i < value.length; i++) {
const ch = value[i];
received++;
// Ignore XON, XOFF
if (ch === this.XON || ch === this.XOFF) {
continue;
}
if (ch === 0x24) { // '$'
incmd = true;
cksum = 0;
param = "";
recv_cmd = "";
result = [];
continue;
}
if (!incmd) {
continue;
}
if (ch !== 0x2A) { // Not '*'
cksum ^= ch;
}
if (ch === 0x2C || ch === 0x2A) { // ',' or '*'
if (param.length) {
if (!recv_cmd.length) {
recv_cmd = param;
} else {
result.push(param);
}
}
param = "";
if (ch === 0x2A) { // '*'
// Read checksum (2 hex characters)
const cksum_s = String.fromCharCode(value[++i]) + String.fromCharCode(value[++i]);
const cksum_r = parseInt(cksum_s, 16);
// Skip CR, LF
i += 2;
if (cksum_r === cksum && recv_cmd === command) {
return result;
}
}
continue;
}
param += String.fromCharCode(ch);
}
}
throw new Error(`Timeout waiting for response to ${command}`);
}
/**
* Get list of available tracks
*/
getTrackList() {
return this.saved_tracks.map((track, index) => {
return {
index: index,
startDate: track.startDate,
endDate: track.endDate,
duration: Math.floor((track.endDate - track.startDate) / 1000)
};
});
}
/**
* Select tracks for download
*/
selectTracks(indices) {
this.selected_tracks = indices;
}
/**
* Download all selected tracks
*/
async downloadSelectedTracks(progressCallback = null) {
return await this.downloadTracklog(progressCallback);
}
/**
* Get the flight info data
* @returns {Object|null} The flight info data or null if not available
*/
getFlightInfo() {
return this.flightInfo;
}
}
// Export the class
if (typeof module !== 'undefined' && module.exports) {
module.exports = { FlymasterClient };
} else {
window.FlymasterClient = FlymasterClient;
}