commit
a932b425dc
@ -0,0 +1,520 @@ |
|||||||
|
// 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; |
||||||
|
} |
Loading…
Reference in new issue