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

520 lines
14 KiB

// flymaster-client.js
// WebSerial implementation of the Flymaster GPS protocol
class FlymasterClient {
constructor() {
// Constants
this.BAUDRATE = 57600;
this.XON = 0x11;
this.XOFF = 0x13;
this.MAX_LINE = 90;
this.DOWN_TIMEOUT = 1000; // 1 second in milliseconds
// 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 = [];
}
/**
* 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" // We'll handle XON/XOFF in software
});
// 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
*/
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);
}
}
// 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;
}
/**
* Generate NMEA command with checksum
*/
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
*/
async sendCommand(command, parameters = []) {
await this.sendSmplCommand(command, parameters);
return await this.receiveData(command);
}
/**
* Send a command without waiting for response
*/
async sendSmplCommand(command, parameters = []) {
const cmdStr = this.genCommand(command, parameters);
const encoder = new TextEncoder();
const data = encoder.encode(cmdStr);
await this.writer.write(data);
}
/**
* Read data from the device until a valid response is received
*/
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) // '*'
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");
}
/**
* 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;
}
/**
* 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
};
}
/**
* 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.sort((a, b) => b - a); // Sort in descending order
}
/**
* 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;
}
}
// 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();
}
}
// Export the class
if (typeof module !== 'undefined' && module.exports) {
module.exports = { FlymasterClient };
} else {
window.FlymasterClient = FlymasterClient;
}