From a932b425dc0947013284547152b4e4b4f7bb8bec Mon Sep 17 00:00:00 2001 From: Arne Schauf Date: Thu, 15 May 2025 13:30:44 +0200 Subject: [PATCH] add ai client.js --- flymaster-client.js | 520 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 flymaster-client.js diff --git a/flymaster-client.js b/flymaster-client.js new file mode 100644 index 0000000..70a4c8a --- /dev/null +++ b/flymaster-client.js @@ -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; +}