diff --git a/Chat/Readme.md b/Chat/Readme.md new file mode 100644 index 00000000..25dea3f9 --- /dev/null +++ b/Chat/Readme.md @@ -0,0 +1,3 @@ +## Chat + +OpenAI based Chat module diff --git a/Chat/function-calls.js b/Chat/function-calls.js new file mode 100644 index 00000000..b9dc642b --- /dev/null +++ b/Chat/function-calls.js @@ -0,0 +1,1073 @@ +// script for handling function calls + +// error handling +window.onerror = function (msg, url, linenumber) { + alert('Sorry, something went wrong.\n\n' + + 'Please try a hard reload of this page to clear its cache.\n\n' + + 'If the error persists open an issue on the GitHub repo.\n' + + 'Include a copy of the log and the following error message:\n\n' + + msg + '\n' + + 'URL: ' + url + '\n' + + 'Line Number: ' + linenumber) + return false +} +window.addEventListener('unhandledrejection', function (e) { + throw new Error(e.reason.stack) +}) + +// imports +import { mavlink_store } from '../modules/MAVLink/mavlink_store.js'; +import { MAVLink, mavlink_ws } from './shared/mavlink.js'; +import { add_text_to_debug } from "./shared/ui.js"; + +// global variable to store wakeup messages +window.wakeup_schedule = []; + +// handle function call from assistant +async function handle_function_call(name, args) { + // call the function + switch (name) { + case "get_vehicle_type": + // get the vehicle type (e.g. Copter, Plane, Rover, Boat, etc) + return get_vehicle_type(); + case "get_parameter": + // Get a vehicle parameter's value. The full list of available parameters and their values is available using the get_all_parameters function + return await get_parameter(args); + case "get_wakeup_timers": + // Retrieves a list of all active wakeup timers. You can optionally provide a message parameter to filter timers by their associated messages. When specifying the message parameter, you can use regular expressions (regex) to match patterns within the timer messages. This is useful when you want to find timers with specific keywords or patterns in their messages. For example, to retrieve all timers containing the word 'hello', you can use the regex '.*hello.*', where the dot-star (.*) pattern matches any character sequence. + return get_wakeup_timers(args); + case "get_vehicle_location_and_yaw": + // Get the vehicle's current location including latitude, longitude, altitude above sea level and altitude above home + return get_vehicle_location_and_yaw() + case "send_mavlink_set_position_target_global_int": + // Send a mavlink SET_POSITION_TARGET_GLOBAL_INT message to the vehicle. This message is the preferred way to command a vehicle to fly to a specified location or to fly at a specfied velocity + return send_mavlink_set_position_target_global_int(args); + case "get_vehicle_state": + // Get the vehicle state including armed status and (flight) mode + return get_vehicle_state(); + case "get_location_plus_offset": + // Calculate the latitude and longitude given an existing latitude and longitude and distances (in meters) North and East + return get_location_plus_offset(args); + case "send_mavlink_command_int": + // Send a mavlink COMMAND_INT message to the vehicle. Available commands including changing the flight mode, arming, disarming, takeoff and commanding the vehicle to fly to a specific location + return await send_mavlink_command_int(args); + case "get_location_plus_dist_at_bearing": + // Calculate the latitude and longitude given an existing latitude and longitude and a distance in meters and a bearing in degrees + return get_location_plus_dist_at_bearing(args); + case "get_parameter_description": + // Get vehicle parameter descriptions including description, units, min and max + return await get_parameter_description(args); + case "delete_wakeup_timers": + // Delete all active wakeup timers. You can optionally provide a message parameter to filter which timers will be deleted based on their message. When specifying the message parameter, you can use regular expressions (regex) to match patterns within the timer messages. This is useful when you want to delete timers with specific keywords or patterns in their message. For example, to delete all timers containing the word 'hello', you can use the regex '.*hello.*', where the dot-star (.*) pattern matches any character sequence. + return delete_wakeup_timers(args); + case "set_parameter": + // Set a vehicle parameter's value. The full list of parameters is available using the get_all_parameters function + return await set_parameter(args); + case "get_mavlink_message": + // Get a mavlink message including all fields and values sent by the vehicle. The list of available messages can be retrieved using the get_available_mavlink_messages + return get_mavlink_message(args); + case "get_current_datetime": + // Get the current date and time, e.g. 'Saturday, June 24, 2023 6:14:14 PM + add_text_to_debug("get_current_datetime called"); + return getFormattedDate(); + case "get_mode_mapping": + // Get a list of mode names to mode numbers available for this vehicle. If the name or number parameter is provided only that mode's name and number will be returned. If neither name nor number is provided the full list of available modes will be returned + add_text_to_debug("get_mode_mapping called"); + return get_mode_mapping(args); + case "get_all_parameters": + // Get all available parameter names and values + return await get_all_parameters(args); + case "set_wakeup_timer": + // Set a timer to wake you up in a specified number of seconds in the future. This allows taking actions in the future. The wakeup message will appear with the user role but will look something like WAKEUP:. Multiple wakeup messages are supported + return set_wakeup_timer(args); + case "get_available_mavlink_messages": + return mavlink_store.get_available_message_names(); + default: + add_text_to_debug("Unknown function: " + name); + return "Unknown function: " + name; + } +} + +// function calls below here +// returns "Copter", "Plane", "Rover", "Boat", etc or "Unknown" +function get_vehicle_type() { + // get the latest HEARTBEAT message and perform a sanity check + let heartbeat_msg = mavlink_store.get_latest_message(0); + if (!heartbeat_msg || !heartbeat_msg.hasOwnProperty("type")) { + return "unknown because no HEARTBEAT message has been received from the vehicle"; + } + let vehicle_type = heartbeat_msg["type"]; + + // get the vehicle type from the heartbeat message's type field + switch (vehicle_type) { + case mavlink20.MAV_TYPE_FIXED_WING: + case mavlink20.MAV_TYPE_VTOL_DUOROTOR: + case mavlink20.MAV_TYPE_VTOL_QUADROTOR: + case mavlink20.MAV_TYPE_VTOL_TILTROTOR: + return "Plane" + case mavlink20.MAV_TYPE_GROUND_ROVER: + return "Rover"; + case mavlink20.MAV_TYPE_SURFACE_BOAT: + return "Boat"; + case mavlink20.MAV_TYPE_SUBMARINE: + return "Sub"; + case mavlink20.MAV_TYPE_QUADROTOR: + case mavlink20.MAV_TYPE_COAXIAL: + case mavlink20.MAV_TYPE_HEXAROTOR: + case mavlink20.MAV_TYPE_OCTOROTOR: + case mavlink20.MAV_TYPE_TRICOPTER: + case mavlink20.MAV_TYPE_DODECAROTOR: + return "Copter"; + case mavlink20.MAV_TYPE_HELICOPTER: + return "Heli"; + case mavlink20.MAV_TYPE_ANTENNA_TRACKER: + return "Tracker"; + case mavlink20.MAV_TYPE_AIRSHIP: + return "Blimp"; + default: + add_text_to_debug("get_vehicle_type: default, unknown"); + return "unknown"; + } + + // if we got this far we don't know the vehicle type + add_text_to_debug("get_vehicle_type: no match for type:" + heartbeat_msg.type); + return "unknown"; +} + +// return a mapping of mode names to numbers for the current vehicle type +function get_mode_mapping(args) { + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("get_mode_mapping: ERROR parsing args string"); + args = {}; + } + } + + args = args || {}; + + // get name and/or number arguments + let mode_name = args.name ?? null; + if (mode_name != null) { + mode_name = mode_name.toUpperCase(); + } + + let mode_number = args.number ?? null; + if (mode_number != null) { + mode_number = parseInt(mode_number, 10); + } + + // prepare list of modes + let mode_list = []; + let mode_mapping = {}; + + const vehicle_type = get_vehicle_type(); + switch (vehicle_type) { + case "Heli": + case "Blimp": + case "Copter": + mode_mapping = { + "STABILIZE": mavlink20.COPTER_MODE_STABILIZE, + "ACRO": mavlink20.COPTER_MODE_ACRO, + "ALT_HOLD": mavlink20.COPTER_MODE_ALT_HOLD, + "AUTO": mavlink20.COPTER_MODE_AUTO, + "GUIDED": mavlink20.COPTER_MODE_GUIDED, + "LOITER": mavlink20.COPTER_MODE_LOITER, + "RTL": mavlink20.COPTER_MODE_RTL, + "CIRCLE": mavlink20.COPTER_MODE_CIRCLE, + "LAND": mavlink20.COPTER_MODE_LAND, + "DRIFT": mavlink20.COPTER_MODE_DRIFT, + "SPORT": mavlink20.COPTER_MODE_SPORT, + "FLIP": mavlink20.COPTER_MODE_FLIP, + "AUTOTUNE": mavlink20.COPTER_MODE_AUTOTUNE, + "POSHOLD": mavlink20.COPTER_MODE_POSHOLD, + "BRAKE": mavlink20.COPTER_MODE_BRAKE, + "THROW": mavlink20.COPTER_MODE_THROW, + "AVOID_ADSB": mavlink20.COPTER_MODE_AVOID_ADSB, + "GUIDED_NOGPS": mavlink20.COPTER_MODE_GUIDED_NOGPS, + "SMART_RTL": mavlink20.COPTER_MODE_SMART_RTL, + "FLOWHOLD": mavlink20.COPTER_MODE_FLOWHOLD, + "FOLLOW": mavlink20.COPTER_MODE_FOLLOW, + "ZIGZAG": mavlink20.COPTER_MODE_ZIGZAG, + "SYSTEMID": mavlink20.COPTER_MODE_SYSTEMID, + "AUTOROTATE": mavlink20.COPTER_MODE_AUTOROTATE, + "AUTO_RTL": mavlink20.COPTER_MODE_AUTO_RTL + }; + break; + case "Plane": + mode_mapping = { + "MANUAL": mavlink20.PLANE_MODE_MANUAL, + "CIRCLE": mavlink20.PLANE_MODE_CIRCLE, + "STABILIZE": mavlink20.PLANE_MODE_STABILIZE, + "TRAINING": mavlink20.PLANE_MODE_TRAINING, + "ACRO": mavlink20.PLANE_MODE_ACRO, + "FLY_BY_WIRE_A": mavlink20.PLANE_MODE_FLY_BY_WIRE_A, + "FLY_BY_WIRE_B": mavlink20.PLANE_MODE_FLY_BY_WIRE_B, + "CRUISE": mavlink20.PLANE_MODE_CRUISE, + "AUTOTUNE": mavlink20.PLANE_MODE_AUTOTUNE, + "AUTO": mavlink20.PLANE_MODE_AUTO, + "RTL": mavlink20.PLANE_MODE_RTL, + "LOITER": mavlink20.PLANE_MODE_LOITER, + "TAKEOFF": mavlink20.PLANE_MODE_TAKEOFF, + "AVOID_ADSB": mavlink20.PLANE_MODE_AVOID_ADSB, + "GUIDED": mavlink20.PLANE_MODE_GUIDED, + "INITIALIZING": mavlink20.PLANE_MODE_INITIALIZING, + "QSTABILIZE": mavlink20.PLANE_MODE_QSTABILIZE, + "QHOVER": mavlink20.PLANE_MODE_QHOVER, + "QLOITER": mavlink20.PLANE_MODE_QLOITER, + "QLAND": mavlink20.PLANE_MODE_QLAND, + "QRTL": mavlink20.PLANE_MODE_QRTL, + "QAUTOTUNE": mavlink20.PLANE_MODE_QAUTOTUNE, + "QACRO": mavlink20.PLANE_MODE_QACRO, + "THERMAL": mavlink20.PLANE_MODE_THERMAL + }; + break; + case "Boat": + case "Rover": + mode_mapping = { + "MANUAL": mavlink20.ROVER_MODE_MANUAL, + "ACRO": mavlink20.ROVER_MODE_ACRO, + "STEERING": mavlink20.ROVER_MODE_STEERING, + "HOLD": mavlink20.ROVER_MODE_HOLD, + "LOITER": mavlink20.ROVER_MODE_LOITER, + "FOLLOW": mavlink20.ROVER_MODE_FOLLOW, + "SIMPLE": mavlink20.ROVER_MODE_SIMPLE, + "AUTO": mavlink20.ROVER_MODE_AUTO, + "RTL": mavlink20.ROVER_MODE_RTL, + "SMART_RTL": mavlink20.ROVER_MODE_SMART_RTL, + "GUIDED": mavlink20.ROVER_MODE_GUIDED, + "INITIALIZING": mavlink20.ROVER_MODE_INITIALIZING + }; + break; + case "Sub": + mode_mapping = { + "STABILIZE": mavlink20.SUB_MODE_STABILIZE, + "ACRO": mavlink20.SUB_MODE_ACRO, + "ALT_HOLD": mavlink20.SUB_MODE_ALT_HOLD, + "AUTO": mavlink20.SUB_MODE_AUTO, + "GUIDED": mavlink20.SUB_MODE_GUIDED, + "CIRCLE": mavlink20.SUB_MODE_CIRCLE, + "SURFACE": mavlink20.SUB_MODE_SURFACE, + "POSHOLD": mavlink20.SUB_MODE_POSHOLD, + "MANUAL": mavlink20.SUB_MODE_MANUAL + }; + break; + case "Tracker": + mode_mapping = { + "MANUAL": mavlink20.TRACKER_MODE_MANUAL, + "STOP": mavlink20.TRACKER_MODE_STOP, + "SCAN": mavlink20.TRACKER_MODE_SCAN, + "SERVO_TEST": mavlink20.TRACKER_MODE_SERVO_TEST, + "AUTO": mavlink20.TRACKER_MODE_AUTO, + "INITIALIZING": mavlink20.TRACKER_MODE_INITIALIZING + }; + break; + default: + // maybe we don't know the vehicle type + add_text_to_debug("get_mode_mapping: unknown vehicle type: " + vehicle_type); + return `get_mode_mapping: failed to retrieve mode mapping: unknown vehicle type: ${vehicle_type}`; + } + + // handle request for all modes + if (mode_name === null && mode_number === null) { + for (let mname in mode_mapping) { + let mnumber = mode_mapping[mname]; + mode_list.push({ "name": mname.toUpperCase(), "number": mnumber }); + } + } + // handle request using mode name + else if (mode_name !== null) { + for (let mname in mode_mapping) { + if (mname.toUpperCase() === mode_name) { + mode_list.push({ "name": mname.toUpperCase(), "number": mode_mapping[mname] }); + } + } + } + // handle request using mode number + else if (mode_number !== null) { + for (let mname in mode_mapping) { + let mnumber = mode_mapping[mname]; + if (mnumber === mode_number) { + mode_list.push({ "name": mname.toUpperCase(), "number": mnumber }); + } + } + } + + // return list of modes + return mode_list; +} + +function get_mavlink_message(args) { + // Check if it's a string and parse it + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + return { status: "error", message: "Invalid JSON in arguments" }; + } + } + + // check if name is provided + if (!args || !args.hasOwnProperty("message")) { + add_text_to_debug("get_mavlink_message: message is null"); + return "get_mavlink_message: message is Null"; + } + + const msg = mavlink_store.find_message_by_name(args.message); + if (!msg) { + return "get_mavlink_message: message not found"; + } + return msg; +} + +function get_parameter(args) { + try { + return new Promise((resolve, reject) => { + // Check WebSocket ready + if (!MAVLink || !mavlink_ws || mavlink_ws.readyState !== WebSocket.OPEN) { + reject("MAVLink or WebSocket not ready"); + return; + } + + // Parse input + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("get_parameter: ERROR parsing args string"); + reject("Invalid arguments: JSON parse error"); + return; + } + } + + if (!args || !args.name) { + reject("get_parameter: name not specified"); + return; + } + + const param_name = args.name.trim(); + + // Register resolver + const resolver = (value) => { + clearTimeout(timeoutId); + delete window.pending_param_requests[param_name]; + resolve(value); + }; + + // Register in global pending requests + window.pending_param_requests[param_name] = resolver; + + // Send PARAM_REQUEST_READ + try { + const message = new mavlink20.messages.param_request_read( + 1, // target_system + 1, // target_component + param_name, // param_id + -1 // use param_id + ); + + const pkt = message.pack(MAVLink); + mavlink_ws.send(Uint8Array.from(pkt)); + + add_text_to_debug(`Sent PARAM_REQUEST_READ for ${param_name}`); + } catch (error) { + delete window.pending_param_requests[param_name]; + reject("Error sending PARAM_REQUEST_READ: " + error); + return; + } + + // timeout: 5 seconds + const timeoutId = setTimeout(() => { + if (window.pending_param_requests[param_name]) { + delete window.pending_param_requests[param_name]; + add_text_to_debug(`Timeout waiting for PARAM_VALUE for ${param_name}`); + reject(`No PARAM_VALUE received for "${param_name}" within 10 seconds. The parameter may not exist on this vehicle.`); + } + }, 5000); + }); + } catch (error) { + add_text_to_debug("get_parameter: Error: " + error); + return Promise.reject("get_parameter: Error retrieving parameter"); + } +} + +// returns true if string contains regex characters +function contains_regex(string) { + const regex_characters = ".^$*+?{}[]\\|()"; + for (const char of regex_characters) { + if (string.includes(char)) { + return true; + } + } + return false; +} + +function get_all_parameters() { + return new Promise((resolve, reject) => { + if (!MAVLink || !mavlink_ws || mavlink_ws.readyState !== WebSocket.OPEN) { + reject("MAVLink or WebSocket not ready"); + return; + } + + if (window.pending_all_params_request) { + reject("Another get_all_parameters request is already in progress"); + return; + } + + // Set up the pending request + window.pending_all_params_request = { + params: {}, + resolve: resolve + }; + + try { + const message = new mavlink20.messages.param_request_list( + 1, // target_system + 1 // target_component + ); + + const pkt = message.pack(MAVLink); + mavlink_ws.send(Uint8Array.from(pkt)); + + add_text_to_debug("Sent PARAM_REQUEST_LIST to vehicle"); + } catch (error) { + window.pending_all_params_request = null; + reject("Error sending PARAM_REQUEST_LIST: " + error); + return; + } + + // timeout: 15 seconds + setTimeout(() => { + if (window.pending_all_params_request) { + window.pending_all_params_request = null; + reject("Timeout waiting for all parameters"); + } + }, 15000); + }); +} + +// Global cache for parameter metadata +const parameter_metadata_cache = {}; + +const parameter_url_map = { + Tracker: "https://autotest.ardupilot.org/Parameters/AntennaTracker/apm.pdef.json", + Copter: "https://autotest.ardupilot.org/Parameters/ArduCopter/apm.pdef.json", + Plane: "https://autotest.ardupilot.org/Parameters/ArduPlane/apm.pdef.json", + Rover: "https://autotest.ardupilot.org/Parameters/Rover/apm.pdef.json", + Sub: "https://autotest.ardupilot.org/Parameters/ArduSub/apm.pdef.json" +}; + +async function load_parameter_metadata(vehicle_type) { + if (parameter_metadata_cache[vehicle_type]) { + add_text_to_debug(`Using cached parameter metadata for ${vehicle_type}`); + return parameter_metadata_cache[vehicle_type]; + } + + const url = parameter_url_map[vehicle_type]; + if (!url) { + add_text_to_debug(`No parameter definition URL found for vehicle type: ${vehicle_type}`); + return null; + } + + try { + add_text_to_debug(`Fetching parameter metadata for ${vehicle_type} from ${url}`); + const res = await fetch(url); + if (!res.ok) { + add_text_to_debug(`Failed to fetch parameter metadata for ${vehicle_type}: ${res.status}`); + return null; + } + add_text_to_debug(`Successfully fetched parameter metadata for ${vehicle_type}`); + + const data = await res.json(); + parameter_metadata_cache[vehicle_type] = data; + return data; + + } catch (error) { + add_text_to_debug(`Error fetching parameter metadata for ${vehicle_type}:`, error); + return null; + } +} + +function find_param_in_tree(tree, vehicle_type, param_upper) { + for (const [key, val] of Object.entries(tree)) { + if (typeof val === "object") { + if (key.toUpperCase() === param_upper) { + return { key, meta: val }; + } + if (`${vehicle_type}:${param_upper}` === key.toUpperCase()) { + return { key, meta: val }; + } + const result = find_param_in_tree(val, vehicle_type, param_upper); + if (result) return result; + } + } + return null; +} + +function get_single_parameter_description(param_tree, vehicle_type, param_name) { + const param_upper = param_name.toUpperCase(); + + const result = find_param_in_tree(param_tree, vehicle_type, param_upper); + + if (!result) return null; + + return result; +} + + +async function get_parameter_description(args) { + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch { + return "get_parameter_description: invalid JSON"; + } + } + + if (!args || !args.name) { + return "get_parameter_description: name not specified"; + } + + const param_name = args.name; + const vehicle_type = get_vehicle_type(); + const param_tree = await load_parameter_metadata(vehicle_type); + if (!param_tree) { + return `get_parameter_description: No metadata for vehicle type: ${vehicle_type}`; + } + + const results = {}; + + if (contains_regex(param_name)) { + const pattern = new RegExp(param_name.replace(/\*/g, ".*"), "i"); + + for (const [key, val] of Object.entries(param_tree)) { + if (pattern.test(key)) { + const single = get_single_parameter_description(param_tree, vehicle_type, key); + if (single) results[key] = single; + } else if (typeof val === "object") { + for (const [subkey] of Object.entries(val)) { + if (pattern.test(subkey)) { + const single = get_single_parameter_description(param_tree, vehicle_type, subkey); + if (single) results[subkey] = single; + } + } + } + } + + if (Object.keys(results).length === 0) { + return `get_parameter_description: No parameters matched pattern: ${param_name}`; + } + return results; + } + + // Exact + const single = get_single_parameter_description(param_tree, vehicle_type, param_name); + if (!single) { + return `get_parameter_description: ${param_name} parameter description not found`; + } + + results[param_name] = single; + return results; +} + +// send a PARAM_SET message to change a vehicle parameter +function set_parameter(args) { + if (!MAVLink || !mavlink_ws) { + return "set_parameter: MAVLink or WebSocket not ready"; + } + + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("ERROR set_parameter: Could not parse args JSON"); + return "Invalid arguments: JSON parse error"; + } + } + + if (!args || !args.hasOwnProperty("name") || args.value === undefined) { + return "set_parameter: name and value required"; + } + + const name = args.name ? args.name.trim() : null; + if (!name) { + return "set_parameter: name cannot be empty"; + } + + const value = parseFloat(args.value); + const message = new mavlink20.messages.param_set( + 1, // target system + 1, // target component + name, + value, + mavlink20.MAV_PARAM_TYPE_REAL32 + ); + + if (mavlink_ws.readyState === WebSocket.OPEN) { + try { + const pkt = message.pack(MAVLink); + mavlink_ws.send(Uint8Array.from(pkt)); + return "set_parameter: sent"; + } catch (error) { + add_text_to_debug("Error sending PARAM_SET: " + error); + return "set_parameter: send failed"; + } + } + return "set_parameter: WebSocket not open"; +} + +function send_mavlink_set_position_target_global_int(args) { + if (!MAVLink || !mavlink_ws) { + add_text_to_debug("MAVLink or WebSocket not ready"); + return { success: false, error: "MAVLink not initialized" }; + } + + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("ERROR: Could not parse args JSON"); + return "Invalid arguments: JSON parse error"; + } + } + + try { + const time_boot_ms = args.time_boot_ms ?? 0; + const target_system = args.target_system ?? 1; + + const target_component = args.target_component ?? 1; + const coordinate_frame = args.coordinate_frame ?? 5; + const type_mask = args.type_mask ?? 0; // ignore all position, velocity and acceleration except for the ones we set + + const lat_int = args.latitude !== undefined ? Math.round(args.latitude * 1e7) : 0; + const lon_int = args.longitude !== undefined ? Math.round(args.longitude * 1e7) : 0; + const alt = args.alt !== undefined ? args.alt : 0; // in meters + + // Velocity + const vx = args.vx ?? 0; + const vy = args.vy ?? 0; + const vz = args.vz ?? 0; + + // Acceleration (ignored if not needed) + const afx = args.afx ?? 0; + const afy = args.afy ?? 0; + const afz = args.afz ?? 0; + + // Yaw and yaw_rate + const yaw = args.yaw ?? 0; + const yaw_rate = args.yaw_rate ?? 0; + + + // sanity check arguments + if (type_mask === 3576) { + // if position is specified check lat, lon, alt are provided + if (!args.hasOwnProperty("latitude")) { + return "send_mavlink_set_position_target_global_int: latitude field required"; + } + if (!args.hasOwnProperty("longitude")) { + return "send_mavlink_set_position_target_global_int: longitude field required"; + } + if (!args.hasOwnProperty("alt")) { + return "send_mavlink_set_position_target_global_int: alt field required"; + } + } + + const message = new mavlink20.messages.set_position_target_global_int( + time_boot_ms, target_system, target_component, + coordinate_frame, type_mask, + lat_int, lon_int, alt, + vx, vy, vz, afx, afy, afz, + yaw, yaw_rate + ); + + if (mavlink_ws && mavlink_ws.readyState === WebSocket.OPEN) { + try { + const pkt = message.pack(MAVLink); + mavlink_ws.send(Uint8Array.from(pkt)); + + } catch (error) { + add_text_to_debug("Error sending COMMAND_INT: " + error); + return "command_int not sent. Error sending COMMAND_INT: " + error; + } + } else { + add_text_to_debug("WebSocket not open. Cannot send COMMAND_INT."); + } + } catch (error) { + add_text_to_debug("Error sending SET_POSITION_TARGET_GLOBAL_INT: " + error); + } + + return "set_position_target_global_int sent"; +} + +// send a mavlink COMMAND_INT message to the vehicle +function send_mavlink_command_int(args) { + return new Promise((resolve, reject) => { + // ensure pending map + if (!window.pending_command_requests) { + window.pending_command_requests = {}; + } + + if (!MAVLink || !mavlink_ws || mavlink_ws.readyState !== WebSocket.OPEN) { + add_text_to_debug("MAVLink or WebSocket not ready"); + return reject({ success: false, error: "MAVLink not initialized or WS not open" }); + } + + // parse JSON if needed + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("send_mavlink_command_int: ERROR parsing args string"); + return reject({ success: false, error: "Invalid arguments: JSON parse error" }); + } + } + args = args || {}; + + if (!args.hasOwnProperty("command")) { + add_text_to_debug("send_mavlink_command_int: missing command"); + return reject({ success: false, error: "command field required" }); + } + + const command = args.command; + const frame = args.frame || 0; // kept for compatibility + const target_system = 1; + const target_component = 1; + const confirmation = 1; + const param1 = args.hasOwnProperty("param1") ? args.param1 : 0; + const param2 = args.hasOwnProperty("param2") ? args.param2 : 0; + const param3 = args.hasOwnProperty("param3") ? args.param3 : 0; + const param4 = args.hasOwnProperty("param4") ? args.param4 : 0; + const x = args.hasOwnProperty("x") ? args.x : 0; + const y = args.hasOwnProperty("y") ? args.y : 0; + const z = args.hasOwnProperty("z") ? args.z : 0; + + // example sanity check + if (command === mavlink20.MAV_CMD_NAV_TAKEOFF && z === 0) { + return reject({ success: false, error: "MAV_CMD_NAV_TAKEOFF requires altitude in z field" }); + } + + let timeoutId; + + try { + // Build COMMAND_LONG message + const message = new mavlink20.messages.command_long( + target_system, target_component, + command, confirmation, + param1, param2, param3, param4, + x, y, z + ); + + const pkt = message.pack(MAVLink); + mavlink_ws.send(Uint8Array.from(pkt)); + add_text_to_debug(`Sent COMMAND_INT ${command}, awaiting ACK`); + } catch (error) { + return reject({ success: false, error: "Error sending COMMAND_INT: " + error }); + } + + // register resolver with cleanup + window.pending_command_requests[command] = (ackMsg) => { + if (timeoutId) clearTimeout(timeoutId); + delete window.pending_command_requests[command]; + add_text_to_debug(`Resolving COMMAND_INT ${command} → result ${ackMsg.result}`); + resolve({ success: true, ack: ackMsg }); + }; + + // set a timeout to reject if no ACK arrives + timeoutId = setTimeout(() => { + if (window.pending_command_requests[command]) { + delete window.pending_command_requests[command]; + add_text_to_debug(`Timeout waiting for COMMAND_ACK ${command}`); + reject({ success: false, error: "COMMAND_ACK timeout" }); + } + }, 5000); + }); +} + + +// get the current time and date as a string. E.g. 'Saturday, June 24, 2023 6:14:14 PM' +function getFormattedDate() { + const options = { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: true + }; + return new Date().toLocaleString('en-US', options); +} + +// set a wakeup timer +function set_wakeup_timer(args) { + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("ERROR set_wakeup_timer: Could not parse args JSON"); + return "Invalid arguments: JSON parse error"; + } + } + + // check required arguments are specified + const seconds = args.seconds ?? -1; + if (seconds < 0) { + return "set_wakeup_timer: seconds not specified"; + } + const message = args.message ?? null; + if (message === null) { + return "set_wakeup_timer: message not specified"; + } + + // add timer to wakeup schedule + const triggerTime = Date.now() + seconds * 1000; // seconds → milliseconds → match Date.now() + add_text_to_debug("set_wakeup_timer: triggerTime: " + new Date(triggerTime).toLocaleString()); + window.wakeup_schedule.push({ time: triggerTime, message: message }); + return "set_wakeup_timer: wakeup timer set"; +} + +// get wake timers +function get_wakeup_timers(args) { + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("ERROR set_wakeup_timer: Could not parse args JSON"); + return "Invalid arguments: JSON parse error"; + } + } + + try { + + // check message argument, default to null meaning all + const message = args.message ?? null; + // prepare list of matching timers + let matching_timers = []; + + // handle simple case of all timers + if (message === null) { + matching_timers = window.wakeup_schedule; + add_text_to_debug("get_wakeup_timers: returning all timers"); + } + + // handle regex in message + else if (contains_regex(message)) { + const pattern = new RegExp(message, "i"); // ignore case + for (const wakeup_timer of window.wakeup_schedule) { + if (pattern.test(wakeup_timer.message)) { + matching_timers.push(wakeup_timer); + } + } + add_text_to_debug("get_wakeup_timers: returning timers matching regex: " + message); + } + + // handle case of a specific message + else { + for (const wakeup_timer of window.wakeup_schedule) { + if (wakeup_timer.message === message) { + matching_timers.push(wakeup_timer); + } + } + add_text_to_debug("get_wakeup_timers: returning timers matching message: " + message); + } + + // return matching timers + return matching_timers; + + } catch (e) { + add_text_to_debug("ERROR get_wakeup_timers: " + e); + return "Invalid arguments: JSON parse error"; + } +} + +// delete wake timers +function delete_wakeup_timers(args) { + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("ERROR set_wakeup_timer: Could not parse args JSON"); + return "Invalid arguments: JSON parse error"; + } + } + + // check message argument, default to all + const message = args.message ?? null; + + // find matching timers + let numDeleted = 0; + + // handle simple case of deleting all timers + if (message === null) { + numDeleted = window.wakeup_schedule.length; + window.wakeup_schedule.length = 0; + add_text_to_debug("delete_wakeup_timers: deleted all timers"); + } + // handle regex in message + else if (contains_regex(message)) { + const pattern = new RegExp(message, "i"); + for (let i = window.wakeup_schedule.length - 1; i >= 0; i--) { + if (pattern.test(window.wakeup_schedule[i].message)) { + window.wakeup_schedule.splice(i, 1); + numDeleted++; + } + } + add_text_to_debug("delete_wakeup_timers: deleted timers matching regex: " + message); + } + // handle simple case of a single message + else { + for (let i = window.wakeup_schedule.length - 1; i >= 0; i--) { + if (window.wakeup_schedule[i].message === message) { + window.wakeup_schedule.splice(i, 1); + numDeleted++; + } + } + add_text_to_debug("delete_wakeup_timers: deleted timers matching message: " + message); + } + + // return number deleted and remaining + return `delete_wakeup_timers: deleted ${numDeleted} timers, ${window.wakeup_schedule.length} remaining`; +} + +//get the vehicle's location and yaw +function get_vehicle_location_and_yaw() { + // get GLOBAL_POSITION_INT + const gpi = mavlink_store.get_latest_message(33); + + let lat_deg = 0 + let lon_deg = 0 + let alt_amsl_m = 0 + let alt_rel_m = 0 + let yaw_deg = 0 + + if (gpi) { + lat_deg = gpi.lat * 1e-7 + lon_deg = gpi.lon * 1e-7 + alt_amsl_m = gpi.alt * 1e-3 + alt_rel_m = gpi.relative_alt * 1e-3 + yaw_deg = gpi.hdg * 1e-2 + } + + const location = { + "latitude": lat_deg, + "longitude": lon_deg, + "altitude_amsl": alt_amsl_m, + "altitude_above_home": alt_rel_m, + "yaw": yaw_deg + } + + return location; +} + +//get vehicle state +function get_vehicle_state() { + //get latest HEARTBEAT message + const heartbeat_msg = mavlink_store.get_latest_message(0); + //sanity check + if (!heartbeat_msg || !heartbeat_msg.hasOwnProperty("base_mode") || !heartbeat_msg.hasOwnProperty("custom_mode")) { + return "unknown because no HEARTBEAT message has been received from the vehicle"; + } + //get the armed state flag by applying mask to base_model property + const armed_flag = (heartbeat_msg["base_mode"] & mavlink20.MAV_MODE_FLAG_SAFETY_ARMED) > 0; + //get mode number from custom_mode property + const mode_number = heartbeat_msg["custom_mode"]; + + return { + "armed": armed_flag, + "mode": mode_number + } +} + +// Calculate the latitude and longitude given distances (in meters) North and East +function get_location_plus_offset(args) { + // check if args is a string, if so, parse it + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("ERROR: Could not parse args JSON"); + return "Invalid arguments: JSON parse error"; + } + } + + const lat = args.latitude ?? 0; + const lon = args.longitude ?? 0; + const dist_north = args.distance_north ?? 0; + const dist_east = args.distance_east ?? 0; + const { latitude: lat_with_offset, longitude: lon_with_offset } = + get_latitude_longitude_given_offset(lat, lon, dist_north, dist_east); + + return { + latitude: lat_with_offset, + longitude: lon_with_offset + }; +} + +// Calculate the latitude and longitude given a distance (in meters) and bearing (in degrees) +function get_location_plus_dist_at_bearing(args) { + // If args is a string, parse it + if (typeof args === "string") { + try { + args = JSON.parse(args); + } catch (e) { + add_text_to_debug("get_location_plus_dist_at_bearing: ERROR parsing args string"); + return { latitude: 0, longitude: 0 }; + } + } + + const lat = args.latitude ?? 0; + const lon = args.longitude ?? 0; + const distance = args.distance ?? 0; + const bearing_deg = args.bearing ?? 0; + + const dist_north = Math.cos(bearing_deg * Math.PI / 180) * distance; + const dist_east = Math.sin(bearing_deg * Math.PI / 180) * distance; + + const { latitude: lat_with_offset, longitude: lon_with_offset } = + get_latitude_longitude_given_offset(lat, lon, dist_north, dist_east); + + return { + latitude: lat_with_offset, + longitude: lon_with_offset + }; + + +} + +// wrap latitude to range -90 to 90 +function wrap_latitude(latitude_deg) { + if (latitude_deg > 90) { + return 180 - latitude_deg; + } + if (latitude_deg < -90) { + return -(180 + latitude_deg); + } + return latitude_deg; +} +// wrap longitude to range -180 to 180 +function wrap_longitude(longitude_deg) { + if (longitude_deg > 180) { + return longitude_deg - 360; + } + if (longitude_deg < -180) { + return longitude_deg + 360; + } + return longitude_deg; +} + +// calculate latitude and longitude given distances (in meters) North and East +// returns latitude and longitude in degrees +function get_latitude_longitude_given_offset(latitude, longitude, dist_north, dist_east) { + const lat_lon_to_meters_scaling = 89.8320495336892 * 1e-7; + const lat_diff = dist_north * lat_lon_to_meters_scaling; + const lon_diff = dist_east * lat_lon_to_meters_scaling / Math.max(0.01, Math.cos((latitude + lat_diff) * Math.PI / 180 / 2)); + return { + latitude: wrap_latitude(latitude + lat_diff), + longitude: wrap_longitude(longitude + lon_diff) + }; +} + +// make it visible to other modules +window.handle_function_call = handle_function_call; diff --git a/Chat/index.html b/Chat/index.html new file mode 100644 index 00000000..84a97579 --- /dev/null +++ b/Chat/index.html @@ -0,0 +1,263 @@ + + + + + + + ArduPilot AI Chat Control + + + + + + + + + + + + + + + +
+ + + + + +
+
+

Choose Session Mode

+

Select how to run this session.

+
+ + +
+
+
+
Realtime
+
    +
  • Very low latency streaming
  • +
  • Session lasts about 28 minutes, then auto-reconnects with a summary
  • +
  • History is temporary, older sessions cannot be resumed
  • +
+
+
+
Assistant
+
    +
  • Slower, turn-based text interaction.
  • +
  • Conversation thread is stored and can be resumed later.
  • +
+
+
+
+
+ + +
+ + +
+ +
+

AI Chat

+
+ + + +
+ +
+
+
+ +
+
+

Welcome! I'm your ArduPilot AI assistant.

+

You can control the drone with commands like:

+
    +
  • "Arm the drone"
  • +
  • "Take off to 10 meters"
  • +
  • "Fly north for 50 meters"
  • +
  • "Return to launch"
  • +
+
+
+ + +
+ +
+ + +
+ + + +
+
+ + +
+ + +
+
+
+ + +
+
+

Configuration

+
+
+
+
+ +
+ + +
+ +
+ +
+ + +
+ + +
+ +
+ + +
+
+ + + + + +
+ + +
+
+ +
+
+

Debug Output

+ +
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Chat/main.js b/Chat/main.js new file mode 100644 index 00000000..4eed075a --- /dev/null +++ b/Chat/main.js @@ -0,0 +1,60 @@ +// Handles chat mode selection and initialization. + +import { add_text_to_chat, add_text_to_debug, setChatBusy } from "./shared/ui.js"; +import { loadTextFile, loadJSONFile, loadInstructions } from "./shared/resource_loader.js"; + +let currentMode = null; // "realtime" or "assistant" +let modeApi = null; // module ref that implements init and start + +function showApp() { + document.getElementById("modeChooser").classList.add("hidden"); + document.getElementById("appRoot").classList.remove("hidden"); +} + +async function loadMode(mode) { + if (currentMode) return; // lock after first pick + currentMode = mode; + + showApp(); + add_text_to_debug("Mode chosen: " + mode); + add_text_to_debug('waiting for mode initialization...'); + + if (mode === "realtime") { + // show session timer + document.getElementById("sessionTimer").classList.remove("hidden"); + document.getElementById("sessionTimer").classList.remove("lg:hidden"); + // remove assistant ID + document.getElementById("assistantIdContainer").classList.add("hidden"); + // remove assistantThreadIdContainer + document.getElementById("assistantThreadIdContainer").classList.add("hidden"); + // add sessionIdContainer + document.getElementById("sessionIdContainer").classList.remove("hidden"); + + // load realtime mode + modeApi = await import("./modes/realtime.js"); + } else { + modeApi = await import("./modes/assistant.js"); + // Assistant-only: ask to resume previous thread + const stored = localStorage.getItem('thread_id'); + if (stored) { + const ok = confirm("Continue your previous chat? Click Cancel to start a new session."); + if (ok) document.getElementById('assistantThreadId').value = stored; + else localStorage.removeItem('thread_id'); + } + } + + // pass shared UI helpers and DOM ids the module needs + await modeApi.initMode({ + add_text_to_chat, + add_text_to_debug, + setChatBusy, + loadTextFile, + loadJSONFile, + loadInstructions, + }); + + add_text_to_debug("Mode initialized"); +} + +document.getElementById("pickRealtime").addEventListener("click", () => loadMode("realtime")); +document.getElementById("pickAssistant").addEventListener("click", () => loadMode("assistant")); \ No newline at end of file diff --git a/Chat/mavlink-connection.js b/Chat/mavlink-connection.js new file mode 100644 index 00000000..74efe8da --- /dev/null +++ b/Chat/mavlink-connection.js @@ -0,0 +1,180 @@ +// MAVLink connection and message handling logic + +// imports +import { mavlink_store } from '../modules/MAVLink/mavlink_store.js'; +import { setMAVLink, setMavlinkWS } from './shared/mavlink.js'; +import { add_text_to_debug } from "./shared/ui.js"; + +window.pending_param_requests = {} +window.pending_all_params_request = null; +window.pending_command_requests = {}; + +// MAVLink connection related functions +let connect_button = document.getElementById("mavlink-connect-button"); +let is_connected = false; // connection state +let mavlink_ws = null; // websocket object +let mavlink_sysid = 254; // system id +let mavlink_compid = MAVLink20Processor.MAV_COMP_ID_MISSIONPLANNER; // component id +let MAVLink = new MAVLink20Processor(null, mavlink_sysid, mavlink_compid); + +// set the MAVLink processor +setMAVLink(MAVLink); + +// toggle connection state (called by Connect/Disconnect button) +function mavlink_toggle_connect() { + if (is_connected) { + mavlink_disconnect(); + } else { + mavlink_connect(); + } +} + +// attach event listener to the connect button +connect_button.addEventListener("click", mavlink_toggle_connect); + +// set the mavlink button connection state +function mavlink_set_connect_state(connected) { + is_connected = connected; + if (connected) { + connect_button.innerText = "Disconnect"; + } else { + connect_button.innerText = "Connect Drone"; + } +} + +// connect to the vehicle +function mavlink_connect() { + // check connection URL + let connect_url = document.getElementById("mavlink-connect-url").value; + if (!connect_url) { + alert("Error: WebSocket URL is empty"); + return; + } + + if (mavlink_ws == null) { + // create a new websocket connection + mavlink_ws = new WebSocket(connect_url); + mavlink_ws.binaryType = "arraybuffer" + + // set up event handlers + mavlink_ws.onopen = function () { + mavlink_set_connect_state(true); + }; + mavlink_ws.onclose = function () { + mavlink_set_connect_state(false); + }; + mavlink_ws.onerror = function () { + mavlink_disconnect(); + }; + + // parse incoming message and forward + mavlink_ws.onmessage = (msg) => { + // sanity check parser has been created + if (MAVLink == null) { + return; + } + // parse message + for (const char of new Uint8Array(msg.data)) { + const mavlink_msg = MAVLink.parseChar(char) + if ((mavlink_msg != null) && (mavlink_msg._id != -1)) { + // got a message with a known ID + mavlink_msg_handler(mavlink_msg); + } + } + } + } + // set the mavlink websocket connection + setMavlinkWS(mavlink_ws); +} + +// disconnect from the vehicle +function mavlink_disconnect() { + if (mavlink_ws != null) { + mavlink_ws.close(); + mavlink_ws = null; + } +} + +// mavlink message handler +function mavlink_msg_handler(msg) { + + // sanity check msg + if (msg == null || msg._id == null) { + return; + } + + // store the message in the message store + mavlink_store.store_message(msg); + + switch (msg._id) { + case 0: // HEARTBEAT + //alert("custom mode:" + msg.custom_mode); + //alert("Got a heartbeat: " + JSON.stringify(msg)); + break; + case 1: // SYS_STATUS + //alert("Got a system status: " + JSON.stringify(msg)); + break; + case 22: // PARAM_VALUE + const param_id = msg.param_id.replace(/\u0000/g, '').trim(); + const param_value = msg.param_value; + const param_index = msg.param_index; + const param_count = msg.param_count; + + add_text_to_debug(`PARAM_VALUE received: ${param_id} = ${param_value}`); + + // Handle single parameter pending requests + if (window.pending_param_requests[param_id]) { + window.pending_param_requests[param_id]({ [param_id]: param_value }); + delete window.pending_param_requests[param_id]; + } + + // All parameters request resolver + if (window.pending_all_params_request) { + window.pending_all_params_request.params[param_id] = param_value; + if (param_index === param_count - 1) { + // This was the last parameter + window.pending_all_params_request.resolve(window.pending_all_params_request.params); + window.pending_all_params_request = null; + } + } + break; + case 24: // GPS_RAW_INT + //alert("Got a GPS raw int: " + JSON.stringify(msg)); + break; + case 30: // ATTITUDE + //alert("Got an attitude: " + JSON.stringify(msg)); + break; + case 33: // GLOBAL_POSITION_INT + //alert("Got a global position int: " + JSON.stringify(msg)); + break; + case 35: // HIGHRES_IMU + //alert("Got a high resolution IMU: " + JSON.stringify(msg)); + break; + case 42: // NAMED_VALUE_FLOAT + //alert("Got a named value float: " + JSON.stringify(msg)); + break; + case 74: // VFR_HUD + //alert("Got a VFR HUD: " + JSON.stringify(msg)); + break; + case 77: // mavlink20.MAVLINK_MSG_ID_COMMAND_ACK + const cmd = msg.command; + const result = msg.result; + + add_text_to_debug(`COMMAND_ACK received: cmd=${cmd} result=${result}`); + + // If someone is waiting for this command’s ACK, resolve their promise + if (window.pending_command_requests[cmd]) { + // resolve with the entire msg, or you could resolve only result + window.pending_command_requests[cmd](msg); + delete window.pending_command_requests[cmd]; + } + break; + case 253: // STATUSTEXT + //alert("Got a status text: " + JSON.stringify(msg)); + break; + default: + //alert("Got a message id: " + JSON.stringify(msg)); + break; + } +} + diff --git a/Chat/modes/assistant.js b/Chat/modes/assistant.js new file mode 100644 index 00000000..816b91d3 --- /dev/null +++ b/Chat/modes/assistant.js @@ -0,0 +1,610 @@ +let initialized = false; + +import OpenAI from "https://cdn.jsdelivr.net/npm/openai@4.85.4/+esm"; +import EventEmitter from 'https://cdn.jsdelivr.net/npm/eventemitter3@5.0.1/+esm' + +export async function initMode(func) { + if (initialized) return; + + const add_text_to_chat = func.add_text_to_chat; + const add_text_to_debug = func.add_text_to_debug; + const setChatBusy = func.setChatBusy; + const loadJSONFile = func.loadJSONFile; + const loadInstructions = func.loadInstructions; + + let wakeup_schedule = window.wakeup_schedule; + + // call check_wakeup_timers once to start the interval + check_wakeup_timers(); + + // constants + const OPENAI_API_KEY = ""; // replace with your OpenAI API key + const OPENAI_MODEL = "gpt-4o" + const OPENAI_ASSISTANT_NAME = "ArduPilot Vehicle Control via MAVLink" + + async function loadTools() { + const tools = []; + for (const file of window.JSON_FUNCTION_FILES) { + const def = await loadJSONFile(file); + if (def) tools.push(def); + } + return tools; + } + + // helper: create event handler if missing + function ensure_event_handler() { + if (!openai_event_handler) { + openai_event_handler = new EventHandler(openai); + if (!openai_event_handler) { + add_text_to_debug('Unable to create event handler'); + return false; + } + openai_event_handler.on("event", openai_event_handler.onEvent.bind(openai_event_handler)); + } + return true; + } + + // helper: load or create thread and optionally load history + async function ensure_thread(loadHistory=true) { + if (openai_thread_id) return true; + let stored = localStorage.getItem('thread_id'); + if (stored) { + openai_thread_id = stored; + const threadEl = document.getElementById("assistantThreadId"); + if (threadEl) threadEl.value = openai_thread_id; + if (loadHistory) { await load_thread_history(openai_thread_id); } + return true; + } + openai_thread_id = await create_thread(); + if (!openai_thread_id) { + add_text_to_debug('Error creating new thread'); + return false; + } + localStorage.setItem('thread_id', openai_thread_id); + const threadEl = document.getElementById("assistantThreadId"); + if (threadEl) threadEl.value = openai_thread_id; + return true; + } + + // helper: finalize assistant context after obtaining assistant id + async function finalize_assistant_context(loadHistory=true) { + if (!openai_assistant_id) return false; + const idEl = document.getElementById("assistantId"); + if (idEl) idEl.value = openai_assistant_id; + if (!(await ensure_thread(loadHistory))) return false; + if (!ensure_event_handler()) return false; + return true; + } + + // helper: create assistant dynamically + async function create_assistant_when_missing() { + add_text_to_debug(`Assistant '${OPENAI_ASSISTANT_NAME}' not found, creating...`); + const instructions = await loadInstructions(); + if (!instructions) { + add_text_to_debug('Failed to load assistant instructions'); + return false; + } + const tools = await loadTools(); + try { + const created = await openai.beta.assistants.create({ + name: OPENAI_ASSISTANT_NAME, + instructions, + model: OPENAI_MODEL, + tools + }); + if (!created?.id) { + add_text_to_debug('Assistant creation failed (no id)'); + return false; + } + openai_assistant_id = created.id; + add_text_to_debug(`Assistant created with id ${openai_assistant_id}`); + return await finalize_assistant_context(false); // no history for brand new thread + } catch (e) { + add_text_to_debug('Assistant creation error: ' + e); + return false; + } + } + + // variables + let openai = null + let openai_assistant_id = null + let openai_thread_id = null + let openai_event_handler = null + + + // chat listener for user input and enter key + document.getElementById("userInput").addEventListener("keypress", function (event) { + if (event.key === "Enter") { + send_message(); + } + }) + // listener for send message button click + document.getElementById("sendMessageButton").addEventListener("click", send_message); + + // get openai API key (use this in API calls) + function get_openai_api_key() { + if (OPENAI_API_KEY.length > 0) { + return OPENAI_API_KEY; + } + return document.getElementById("openai_api_key").value.trim(); + } + + // attach connection handler to the OpenAI connect button + const openaiConnectButton = document.getElementById("openai-connect-button"); + if (openaiConnectButton) { + openaiConnectButton.addEventListener("click", () => { + check_connection(); + }); + } + + const startNewChatButton = document.getElementById("startNewChatButton"); + if (startNewChatButton) { + startNewChatButton.addEventListener("click", () => { + if (confirm("Start a new chat? This will clear the existing conversation.")) { + localStorage.removeItem('thread_id'); + + for (const id of ["assistantThreadId", "assistantId", "assistantRunStatus", "debugOutput"]) { // 3) blank inputs now + const el = document.getElementById(id); if (el) el.value = ""; + } + location.reload(); + } + }); + } + + // unified send function; optional message param used for programmatic sends + async function send_message(arg) { + if (window.chatBusy) return; // guard against rapid multi-clicks + setChatBusy(true); // lock immediately to avoid races during connection setup + + try { + if (!(await check_connection())) { + return; + } + + let message; + let isUserMessage = true; + if (typeof arg === 'string') { + message = arg; + isUserMessage = false; + } else { + const inputEl = document.getElementById("userInput"); + message = inputEl.value; + inputEl.value = ""; // clear early for snappier UX + } + + if (!message || !message.trim()) { + add_text_to_debug("send_message: message is empty"); + return; + } + + // only log user messages + if(isUserMessage) add_text_to_chat(message, "user"); + + const resp = await get_assistant_response(message); + // get_assistant_response streams assistant output; only log explicit errors + if (typeof resp === 'string' && resp.startsWith('get_assistant_response:')) { + add_text_to_debug(resp); + setChatBusy(false); // ensure unlocked on immediate error + } + } catch (err) { + add_text_to_debug("send_message error: " + err); + setChatBusy(false); + } + } + + // + // methods below here interact directly with the OpenAI API + // + + // check connection to OpenAI API and return true on success, false on failure + async function check_connection() { + // check openai API key + if (!get_openai_api_key()) { + setChatBusy(false) + return false; + } + // check openai connection + if (!openai) { + openai = new OpenAI({ apiKey: get_openai_api_key(), dangerouslyAllowBrowser: true }); + if (!openai) { + setChatBusy(false) + return false; + } + } + + // already fully initialized + if (openai_assistant_id && openai_thread_id && openai_event_handler) return true; + + // find existing assistant id if not set + if (!openai_assistant_id) { + const foundId = await find_assistant(OPENAI_ASSISTANT_NAME); + if (foundId) { + openai_assistant_id = foundId; + } else { + const createdOk = await create_assistant_when_missing(); + if (!createdOk) { setChatBusy(false); return false; } + } + } + + // finalize (will create thread + handler if missing) + const ok = await finalize_assistant_context(); + if (!ok) { setChatBusy(false); } + return ok; + } + + // get assistant response based on user input + async function find_assistant(assistant_name) { + // sanity check openai connection + if (!openai) { + return null; + } + + try { + // get a list of all assistants + const assistants_list = await openai.beta.assistants.list({ order: "desc", limit: 20 }); + + // iterate through assistants and find the one with the matching name + let assistant = assistants_list.data.find(a => a.name === assistant_name); + + // return assistant ID if found, otherwise return null + return assistant ? assistant.id : null; + } catch (error) { + // return null in case of an error + return null; + } + } + + // create a new thread + // returns thread id on success, null on failure + async function create_thread() { + // sanity check the assistant id + if (!openai_assistant_id) { + return null; + } + + try { + // create a thread + const new_thread = await openai.beta.threads.create(); + return new_thread ? new_thread.id : null; + } catch (error) { + add_text_to_debug("create_thread error: " + error); + return null; + } + } + + async function load_thread_history(threadId) { + if (!openai) { return; } + try { + const history = await openai.beta.threads.messages.list(threadId, { order: 'asc', limit: 100 }); + for (const msg of history.data) { + if (msg.role === 'assistant' || msg.role === 'user') { + const content = msg.content[0]?.text?.value || ''; + if (content) { + add_text_to_chat(content, msg.role); + } + } + } + } catch (e) { + console.error('Failed to load history', e); + } + } + + + // get assistant response based on user input + async function get_assistant_response(input) { + // sanity check the assistant id + if (!openai_assistant_id) { + return "get_assistant_response: assistant not found"; + } + // sanity check thread + if (!openai_thread_id) { + return "get_assistant_response: thread not found"; + } + + // add a message to the thread + const message = await openai.beta.threads.messages.create(openai_thread_id, { role: "user", content: input }); + + // run the assistant + const stream = await openai.beta.threads.runs.stream(openai_thread_id, { assistant_id: openai_assistant_id, stream: true }) + stream.on('event', (event) => openai_event_handler.emit("event", event)) + } + + + + + class EventHandler extends EventEmitter { + constructor(client) { + super() + this.client = client; + } + + async onEvent(event) { + try { + // print status on html page + document.getElementById("assistantRunStatus").value = event.event; + + // handle each event + switch (event.event) { + // retrieve events that are denoted with 'requires_action' + // since these will have our tool_calls + case "thread.run.requires_action": + await this.handleRequiresAction( + event.data, + event.data.id, + event.data.thread_id, + ) + break; + case "thread.message.delta": + case "thread.run.step.delta": + let delta_text = event.data.delta.content[0].text.value + add_text_to_chat(delta_text, "assistant") + break; + + // events below can be ignored + case "thread.created": + case "thread.message.completed": + case "thread.run.created": + case "thread.run.queued": + case "thread.run.in_progress": + break; + case "thread.run.completed": + case "thread.run.incomplete": + case "thread.run.failed": + setChatBusy(false) + break; + case "thread.run.step.created": + case "thread.run.cancelling": + case "thread.run.step.in_progress": + case "thread.run.cancelled": + case "thread.run.expired": + case "thread.run.step.created": + case "thread.run.step.in_progress": + case "thread.run.step.completed": + case "thread.run.step.failed": + // setChatBusy(false) + // break + case "thread.run.step.cancelled": + case "thread.run.step.expired": + case "thread.message.created": + case "thread.message.in_progress": + break; + case "thread.message.completed": + case "thread.message.incomplete": + case "error": + case "done": + setChatBusy(false) + break; + + // catch unhandled events + default: + add_text_to_debug("Unhandled event: " + event.event) + console.log(event) + } + + } catch (error) { + console.error("Error handling event:", error) + } + } + + // handle requires action event by calling a local function and returning the result to the assistant + async handleRequiresAction(data, runId, threadId) { + try { + const toolOutputs = await Promise.all( + data.required_action.submit_tool_outputs.tool_calls.map(async (toolCall) => { + let output; + try { + output = await window.handle_function_call(toolCall.function.name, toolCall.function.arguments); + } catch (err) { + add_text_to_debug("handle_function_call error: " + err); + output = JSON.stringify({ error: err.toString() }); + } + + output = typeof output === "string" ? output : JSON.stringify(output); + + add_text_to_debug("fn:" + toolCall.function.name + " output:" + output) + return { + tool_call_id: toolCall.id, + output: output + } + }) + ) + + // submit all the tool outputs at the same time + await this.submitToolOutputs(toolOutputs, runId, threadId) + } catch (error) { + console.error("Error processing required action:", error) + } + } + + // return function call results to the assistant + async submitToolOutputs(toolOutputs, runId, threadId) { + try { + // use the submitToolOutputsStream helper + const stream = this.client.beta.threads.runs.submitToolOutputsStream( + threadId, + runId, + { tool_outputs: toolOutputs }, + ) + for await (const event of stream) { + this.emit("event", event) + } + } catch (error) { + console.error("Error submitting tool outputs:", error) + } + } + } + + + + const recordButton = document.getElementById("recordButton"); + + const state = { + mediaRecorder: null, + audioChunks: [], + stream: null, + audioBlob: null, + isRecording: false, + listeners: { + dataavailable: null, + stop: null + } + }; + + async function startRecording(options = {}) { + if (window.chatBusy) { + add_text_to_debug("Recording is busy, please wait"); + return; + } + + if (state.isRecording) { + add_text_to_debug("recording is in progress"); + return; + } + + state.isRecording = true; + state.audioChunks = []; + + try { + const defaultOptions = { + mimeType: "audio/webm", + audioBitsPerSecond: 128000 + }; + const recordingOptions = { ...defaultOptions, ...options }; + + state.stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + + if (MediaRecorder.isTypeSupported(recordingOptions.mimeType)) { + state.mediaRecorder = new MediaRecorder(state.stream, recordingOptions); + } else { + add_text_to_debug(recordingOptions.mimeType + " is not supported, using default codec"); + state.mediaRecorder = new MediaRecorder(state.stream); + } + + state.listeners.dataavailable = event => { + state.audioChunks.push(event.data); + }; + + state.listeners.stop = async () => { + state.audioBlob = new Blob(state.audioChunks, { type: recordingOptions.mimeType }); + cleanupResources(); + await handleTranscription(state.audioBlob); + }; + + state.mediaRecorder.addEventListener("dataavailable", state.listeners.dataavailable); + state.mediaRecorder.addEventListener("stop", state.listeners.stop); + state.mediaRecorder.start(); + + } catch (error) { + state.isRecording = false; + cleanupResources(); + add_text_to_debug("Error accessing microphone: " + error); + setChatBusy(false); + } + } + + function stopRecording() { + if (!state.mediaRecorder || state.mediaRecorder.state === "inactive") { + return false; + } + state.mediaRecorder.stop(); + setChatBusy(true); + add_text_to_debug("Recording stopped, processing audio..."); + return true; + } + + function cleanupResources() { + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + state.stream = null; + } + if (state.mediaRecorder) { + if (state.listeners.dataavailable) { + state.mediaRecorder.removeEventListener("dataavailable", state.listeners.dataavailable); + } + if (state.listeners.stop) { + state.mediaRecorder.removeEventListener("stop", state.listeners.stop); + } + state.mediaRecorder = null; + } + state.isRecording = false; + } + + async function handleTranscription(blob) { + try { + // ensure OpenAI connection exists + // let openai = getOpenAIInstance(); + if (!openai && check_connection) { + const ok = await check_connection(); + if (!ok) { + add_text_to_debug("Unable to connect to OpenAI"); + return; + } + // openai = getOpenAIInstance(); + } + + const formData = new FormData(); + formData.append("file", blob, "recording.webm"); + formData.append("model", "whisper-1"); + // formData.append("language", "en"); + + const apiKey = openai ? openai.apiKey : document.getElementById("openai_api_key").value.trim(); + + const resp = await fetch("https://api.openai.com/v1/audio/transcriptions", { + method: "POST", + headers: { "Authorization": `Bearer ${apiKey}` }, + body: formData + }); + const data = await resp.json(); + const transcript = data.text.trim(); + add_text_to_chat(transcript, "user"); + const response = await get_assistant_response(transcript); + add_text_to_chat(response, "assistant"); + } catch (err) { + add_text_to_debug("Transcription error: " + err); + } + } + + recordButton.addEventListener("click", () => { + if (state.isRecording) { + stopRecording(); + } else { + startRecording().catch(err => { + add_text_to_debug("Failed to start recording: " + err); + }); + } + }); + + // check if any wakeup timers have expired and send messages if they have + // this function never returns so it should be called from a new thread + function check_wakeup_timers() { + setInterval(() => { + // check if any timers are set + if (wakeup_schedule.length === 0) { + return; + } + + const now = Date.now(); + + // iterate backward to safely remove expired timers + for (let i = wakeup_schedule.length - 1; i >= 0; i--) { + if (now >= wakeup_schedule[i].time) { + const message = "WAKEUP:" + wakeup_schedule[i].message; + add_text_to_debug("check_wakeup_timers: sending message: " + message); + + send_message(message); + + wakeup_schedule.splice(i, 1); // remove expired timer + } + } + }, 1000); // wait for one second + } + + + initialized = true; +} diff --git a/Chat/modes/realtime.js b/Chat/modes/realtime.js new file mode 100644 index 00000000..d8cd35c6 --- /dev/null +++ b/Chat/modes/realtime.js @@ -0,0 +1,789 @@ +let initialized = false; + +export async function initMode(func) { + if (initialized) return; + // wire your existing globals to use ui helpers if needed + const add_text_to_chat = func.add_text_to_chat; + const add_text_to_debug = func.add_text_to_debug; + const setChatBusy = func.setChatBusy; + const loadJSONFile = func.loadJSONFile; + const loadInstructions = func.loadInstructions; + + let wakeup_schedule = window.wakeup_schedule; + + // call check_wakeup_timers once to start the interval + check_wakeup_timers(); + + // call loadInstructions + const assistantInstructions = await loadInstructions(); + + const tools = []; + async function loadTools() { + for (const file of window.JSON_FUNCTION_FILES) { + const def = await loadJSONFile(file); + if (def) { + // If "function" key exists and is an object + if (def.function && typeof def.function === "object") { + const { function: funcObj, ...rest } = def; + tools.push({ + ...rest, // keep "type" or any other top-level props + ...funcObj // move everything from "function" up here + }); + } else { + tools.push(def); // unchanged if it doesn't have a "function" property + } + } + } + } + + // call loadTools + await loadTools(); + + /* + * Replace the text of an existing bubble by id. + * You can also toggle muted on or off with opts.muted. + */ + function replace_chat_text(msgId, newText, opts = {}) { + const chatBox = document.getElementById("chatBox"); + const el = chatBox.querySelector(`[data-msg-id="${msgId}"]`); + if (!el) { + // fallback, just add a new user bubble + return add_text_to_chat(newText, opts.role || "user", opts); + } + el.textContent = newText; + if (opts.muted != null) { + if (opts.muted) el.classList.add("muted"); + else el.classList.remove("muted"); + } + chatBox.scrollTop = chatBox.scrollHeight; + return msgId; + } + + + + + // + // methods below here interact directly with the OpenAI API + // + + // --- RealTime API logic --- + + let pc, dataChannel, localStream; + const pendingVoice = new Map(); // item_id -> { placeholderId } + let currentVoicePlaceholderId = null; + let currentSessionId = null; + + + let capturingSummary = false; + let summaryBuffer = ''; + let summaryResolve = null; + let summaryReject = null; + let summaryTimeoutId = null; + + let isSessionReady = false; + let sessionReadyResolvers = []; + + // send helper that queues until the data channel opens + async function sendDC(obj) { + const payload = JSON.stringify(obj); + if (!dataChannel || dataChannel.readyState !== 'open') { + add_text_to_debug('Waiting for data channel to open, cannot send yet'); + return false; + } + dataChannel.send(payload); + return true; + } + + // Event listeners for button clicks + document.getElementById('openai-connect-button').addEventListener('click', connectOpenAI); + + document.getElementById("userInput").addEventListener("keypress", function (event) { + if (event.key === "Enter") { + send_message(); + } + }) + document.getElementById('sendMessageButton').addEventListener('click', send_message); + + // push-to-talk logic: unmute when pressed, mute when released + const recordBtn = document.getElementById('recordButton'); + if (recordBtn) { + recordBtn.addEventListener("click", () => { + if (isRecording) { + stopRecording(); + } else { + startRecording(); + } + }); + } + + async function connectOpenAI() { + setChatBusy(true); + add_text_to_debug('Connecting to OpenAI realtime API...'); + + // Reset session ready state + isSessionReady = false; + + const key = document.getElementById('openai_api_key').value.trim(); + if (!key) { alert('Please enter your API key'); return; } + if (tools.length === 0) { + add_text_to_debug('Warning, no tools loaded'); + return; + } + if (!assistantInstructions) { + add_text_to_debug('Warning, assistant instructions not loaded'); + return; + } + + // Start a session + let session; + try { + const resp = await fetch('https://api.openai.com/v1/realtime/sessions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${key}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'gpt-4o-realtime-preview', + modalities: ['text', 'audio'] + }) + }); + if (!resp.ok) { + const txt = await resp.text().catch(() => ''); + throw new Error(`Session create failed ${resp.status} ${txt}`); + } + session = await resp.json(); + + try { + currentSessionId = session.id || null; + const sessionEl = document.getElementById('sessionId'); + if (sessionEl) sessionEl.value = currentSessionId || ''; + } catch (e) { + add_text_to_debug('Error setting session ID: ' + e.message); + } + setRunStatus('idle'); + + // start the session expiry timer + startSessionExpiryTimer(28.0); + } catch (e) { + add_text_to_debug('Realtime session error: ' + e.message); + return; + } + + // Build peer connection + try { + pc = new RTCPeerConnection({ iceServers: session.ice_servers }); + } catch (e) { + add_text_to_debug('Failed to create peer connection: ' + e.message); + return false; + } + + // basic state logs + pc.onconnectionstatechange = () => { + const state = pc.connectionState; + add_text_to_debug('PC state: ' + state); + + if (state === 'failed' || state === 'disconnected') { + add_text_to_debug('Peer connection ' + state + ' detected'); + } + }; + pc.onsignalingstatechange = () => add_text_to_debug('Signaling: ' + pc.signalingState); + + + // Mic, start muted + try { + localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + localStream.getAudioTracks()[0].enabled = false; + localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); + } catch (e) { + add_text_to_debug('Mic error: ' + e.message); + return false; + } + + // Data channel + dataChannel = pc.createDataChannel('openai_realtime'); + dataChannel.onopen = onChannelOpen; + dataChannel.onmessage = onChannelMessage; + dataChannel.onclose = () => { add_text_to_debug('Data channel closed'); }; + dataChannel.onerror = (e) => add_text_to_debug('Data channel error'); + + // Offer and answer + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + try { + const sdpResp = await fetch(`https://api.openai.com/v1/realtime?model=${session.model}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${key}`, + 'Content-Type': 'application/sdp' + }, + body: offer.sdp + }); + if (!sdpResp.ok) { + const txt = await sdpResp.text().catch(() => ''); + throw new Error(`SDP exchange failed ${sdpResp.status} ${txt}`); + } + const answerSdp = await sdpResp.text(); + await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }); + } catch (e) { + add_text_to_debug('SDP error: ' + e.message); + return; + } + + add_text_to_debug('Connected to OpenAI realtime API'); + } + + function onChannelOpen() { + add_text_to_debug('Data channel open; sending session settings'); + + // Session update + dataChannel.send(JSON.stringify({ + type: 'session.update', + session: { + instructions: assistantInstructions, + tools: tools, + tool_choice: 'auto', + + // audio in, text out, manual push-to-talk + input_audio_format: 'pcm16', + input_audio_transcription: { model: 'whisper-1' }, + turn_detection: null + } + })); + + } + + async function onChannelMessage(event) { + const msg = JSON.parse(event.data); + + switch (msg.type) { + // A new assistant turn has been created + case 'response.created': { + setRunStatus(msg.type); + return; + } + + // Assistant text streaming + case 'response.text.delta': { + setRunStatus(msg.type); + + if (capturingSummary) { + // collect the summary text + if (typeof msg.delta === 'string') summaryBuffer += msg.delta; + return; + } + + add_text_to_chat(msg.delta, 'assistant'); + return; + + } + + case 'response.text.done': { + setRunStatus(msg.type); + if (capturingSummary) { + add_text_to_debug(`summary: ${summaryBuffer}`); + + // parse and resolve + clearTimeout(summaryTimeoutId); + let result = summaryBuffer.trim(); + // try to parse fenced JSON or a bare JSON object + const m = result.match(/```json\s*([\s\S]*?)```/i) || result.match(/\{[\s\S]*\}$/); + if (m) { + try { + const obj = JSON.parse(m[1] || m[0]); + result = obj.brief || result; + } catch (e) { + add_text_to_debug('Error parsing summary JSON: ' + e.message); + } + } + + capturingSummary = false; + const res = summaryResolve; summaryResolve = summaryReject = null; + summaryBuffer = ''; + if (res) res(result); + return; // do not print anything to chat + } + + return; + } + + // assistant has finished its turn + case 'response.done': { + + // handle any tool calls, then ask the model to continue + const calls = Array.isArray(msg.response?.output) + ? msg.response.output.filter(o => o.type === 'function_call') + : []; + + if (calls.length) { + setRunStatus('waiting_function_call_result'); + for (const callItem of calls) { + const raw = callItem.arguments; + const args = typeof raw === 'string' ? JSON.parse(raw) : raw; + + let result; + try { + result = await window.handle_function_call(callItem.name, args); + } catch (err) { + result = { error: String(err) }; + } + + // return the tool result + sendDC({ + type: 'conversation.item.create', + item: { + type: 'function_call_output', + call_id: callItem.call_id, + output: JSON.stringify(result) + } + }); + + // let the model continue in text + sendDC({ + type: 'response.create', + response: { modalities: ['text'] } + }); + } + return; + } + + setRunStatus(msg.type); + setChatBusy(false); // end of assistant's turn + + // Check if we have a pending session rotation after assistant completes its turn + if (pendingSessionRotation) { + add_text_to_debug('Assistant turn completed, starting pending session rotation'); + setTimeout(() => performSessionRotation(), 100); // Small delay to ensure UI updates + } + + return; + } + + // Any server error for this turn + case 'response.error': { + setRunStatus(msg.type); + add_text_to_debug('Realtime error: ' + JSON.stringify(msg)); + + if (capturingSummary) { + clearTimeout(summaryTimeoutId); + capturingSummary = false; + const rej = summaryReject; summaryResolve = summaryReject = null; + summaryBuffer = ''; + if (rej) rej(new Error('response.error during summary')); + } + + return; + } + + // AI confirms it received the audio input + case 'input_audio_buffer.committed': { + setRunStatus(msg.type); + + // attach the server item_id to the bubble we created on press + let phId = currentVoicePlaceholderId; + if (!phId) { + // very rare, but keep a fallback + phId = add_text_to_chat("🎙️ Listening…", "user", { muted: true, append: false }); + } + pendingVoice.set(msg.item_id, { placeholderId: phId }); + currentVoicePlaceholderId = null; + return; + } + + case 'conversation.item.input_audio_transcription.delta': { + // optional live transcript, uncomment if you want to stream it + // add_text_to_debug('mic partial: ' + (msg.delta || '')); + return; + } + + case 'conversation.item.input_audio_transcription.completed': { + setRunStatus(msg.type); + + const entry = pendingVoice.get(msg.item_id); + const text = msg.transcript && msg.transcript.trim() ? msg.transcript.trim() : '(no transcript)'; + if (entry?.placeholderId) { + replace_chat_text(entry.placeholderId, text, { muted: false }); + } else { + add_text_to_chat(text, 'user'); + } + pendingVoice.delete(msg.item_id); + return; + } + + case 'conversation.item.input_audio_transcription.failed': { + setRunStatus(msg.type); + + const entry = pendingVoice.get(msg.item_id); + if (entry?.placeholderId) { + replace_chat_text(entry.placeholderId, 'mic transcription failed', { muted: false }); + } else { + add_text_to_chat('mic transcription failed', 'user'); + } + add_text_to_debug('Transcription error: ' + JSON.stringify(msg.error)); + pendingVoice.delete(msg.item_id); + + // setChatBusy(false); + return; + } + + case 'session.created': { + setRunStatus(msg.type); + return; + } + + case 'session.updated': { + setRunStatus(msg.type); + setChatBusy(false); + + // Mark session as ready and resolve any waiting promises + isSessionReady = true; + sessionReadyResolvers.forEach(resolve => resolve()); + sessionReadyResolvers = []; + + return; + } + + case 'conversation.item.created': { + return; + } + + default: { + // add_text_to_debug('Event: ' + msg.type); + return; + } + } + } + + function setRunStatus(text) { + const el = document.getElementById('assistantRunStatus'); + if (el) el.value = text; + } + + + function send_message(message = null) { + // add_text_to_debug('send_message called with: ' + message); + // If called as an event handler, ignore the event object + if (message && typeof message === "object" && message instanceof Event) { + add_text_to_debug('send_message called as event handler, ignoring event'); + message = null; + } + + if (message == null && window.chatBusy) { + add_text_to_debug('Chat is busy, ignoring message: ' + message); + return; + } + + + // if a message is provided, use it; otherwise get from input field + const input = document.getElementById('userInput'); + const text = message ? message : input.value.trim(); + if (!text) return; + + // If user did not connect yet, auto-connect + if (!currentSessionId) { + add_text_to_debug('Session not started, auto-connecting to OpenAI'); + setChatBusy(true); + connectOpenAI(); + return; + } + + // Only add user-typed input to the chat + if (!message) { + add_text_to_chat(text, 'user'); + } + + sendDC({ + type: 'conversation.item.create', + item: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text }] + } + }); + + sendDC({ + type: 'response.create', + response: { modalities: ['text'] } + }); + + input.value = ''; + setChatBusy(true); + } + + // Mic recording logic + let isRecording = false; + + function ensureMicReady() { + if (!localStream) { + add_text_to_debug('Mic not ready'); + return false; + } + const track = localStream.getAudioTracks()[0]; + if (!track) { + add_text_to_debug('No audio track found'); + return false; + } + return true; + } + + // + function startRecording() { + if (!pc || !dataChannel || dataChannel.readyState !== 'open') { + add_text_to_debug('Data channel not ready, cannot start recording'); + return; + } + + if (window.chatBusy) { + add_text_to_debug('Chat is busy, ignoring recording start'); + return; + } + + if (!ensureMicReady()) return; + if (isRecording) return; + isRecording = true; + + // add a placeholder bubble for the voice input + currentVoicePlaceholderId = add_text_to_chat("🎙️ Listening…", "user", { muted: true, append: false }); + + // start a fresh audio buffer for this turn + sendDC({ type: 'input_audio_buffer.clear' }); + + // unmute the track so audio flows to the peer + localStream.getAudioTracks()[0].enabled = true; + + recordBtn?.classList.add('recording'); + add_text_to_debug('Recording start, mic unmuted'); + } + + function stopRecording() { + if (!ensureMicReady()) return; + if (!isRecording) return; + isRecording = false; + + setChatBusy(true); + + // stop audio flow + localStream.getAudioTracks()[0].enabled = false; + + recordBtn?.classList.remove('recording'); + add_text_to_debug('Recording stop, mic muted'); + + if (currentVoicePlaceholderId) { + replace_chat_text(currentVoicePlaceholderId, 'Transcribing…', { muted: true }); + } + + // finalize this audio turn + sendDC({ type: 'input_audio_buffer.commit' }); + + // ask the model to answer in text + sendDC({ + type: 'response.create', + response: { modalities: ['text'] } + }); + } + + // + // methods below here are for session rotation + // + + async function requestSessionSummary() { + // if already capturing, avoid double requests + if (capturingSummary) { + return Promise.reject(new Error('summary already in progress')); + } + + capturingSummary = true; + summaryBuffer = ''; + + // build a promise that onChannelMessage will resolve + const p = new Promise((resolve, reject) => { + summaryResolve = resolve; + summaryReject = reject; + }); + + const prompt = + `Summarize the conversation so far for continuity to a new realtime session. + Return JSON in one fenced code block with keys: brief, key_state, latest_decisions, todos, safety_notes. + Keep total under 1000 characters.`; + + // ask the model + sendDC({ + type: 'conversation.item.create', + item: { + type: 'message', + role: 'system', + content: [{ type: 'input_text', text: prompt }] + } + }); + sendDC({ type: 'response.create', response: { modalities: ['text'] } }); + add_text_to_debug('Requesting session summary'); + + // safety timeout + clearTimeout(summaryTimeoutId); + summaryTimeoutId = setTimeout(() => { + if (capturingSummary) { + capturingSummary = false; + const rej = summaryReject; summaryResolve = summaryReject = null; + if (rej) rej(new Error('Timeout waiting for session summary')); + } + }, 15000); + + return p; + } + + + function waitForSessionReady() { + if (isSessionReady) return Promise.resolve(); + return new Promise(resolve => sessionReadyResolvers.push(resolve)); + } + + async function rotateSession(summaryText = '') { + add_text_to_debug('Rotating session with summary'); + + try { dataChannel?.close(); } catch { add_text_to_debug('Error closing data channel'); } + try { pc?.close(); } catch { add_text_to_debug('Error closing peer connection'); } + + // reset for the new session + isSessionReady = false; + sessionReadyResolvers = []; + + await connectOpenAI(); + + // wait for the session to be ready + await waitForSessionReady(); + + const note = `Note: + - latest_decisions are actions that have already been completed. Never repeat them. + - todos are pending actions. Execute them only after confirming with the user.`; + + // now it is safe to seed + if (summaryText && summaryText.trim()) { + sendDC({ + type: 'conversation.item.create', + item: { + type: 'message', + role: 'system', + content: [{ type: 'input_text', text: `Previous Session memory:\n${summaryText}\n${note}` }] + } + }); + + sendDC({ + type: 'response.create', + response: { modalities: ['text'] } + }); + } + + add_text_to_debug('New session created'); + } + + + let expiryTimerId = null; + let pendingSessionRotation = false; + + function startSessionExpiryTimer(minutes) { + clearSessionExpiryTimer(); + expiryTimerId = setTimeout(async () => { + // Check if assistant is currently busy or we already have a pending rotation + if (window.chatBusy || pendingSessionRotation) { + add_text_to_debug('Assistant is busy, waiting for current turn to complete before session rotation'); + pendingSessionRotation = true; + return; // Don't start rotation now, let response.done handle it + } + + await performSessionRotation(); + }, minutes * 60 * 1000); + + // start the visible countdown + startCountdown(minutes); + } + + async function performSessionRotation() { + if (pendingSessionRotation && window.chatBusy) { + add_text_to_debug('Session rotation still pending, assistant still busy'); + return; // Still busy, will be called again from response.done + } + + pendingSessionRotation = false; + setChatBusy(true); // Block user from sending new messages + + add_text_to_debug('Pre expiry, requesting summary from model memory'); + let summary = ''; + try { + summary = await requestSessionSummary(); + } catch (e) { + add_text_to_debug('Summary request failed, ' + e.message); + } + await rotateSession(summary); + } + + function clearSessionExpiryTimer() { + if (expiryTimerId) { + clearTimeout(expiryTimerId); + expiryTimerId = null; + } + + // Reset pending rotation flag when clearing timer + pendingSessionRotation = false; + + stopCountdown(); + } + + + let countdownId = null; + + function startCountdown(minutes) { + const el = document.getElementById('sessionExpiryTimerText'); + if (!el) return; + + let remaining = Math.floor(minutes * 60); + + clearInterval(countdownId); + countdownId = setInterval(() => { + const mm = String(Math.floor(remaining / 60)).padStart(2, '0'); + const ss = String(remaining % 60).padStart(2, '0'); + el.textContent = `${mm}:${ss}`; + + if (--remaining < 0) { + clearInterval(countdownId); + el.textContent = "reconnecting…"; + } + }, 1000); + } + + function stopCountdown() { + clearInterval(countdownId); + countdownId = null; + const el = document.getElementById('sessionExpiryTimerText'); + if (el) el.textContent = "reconnecting…"; + } + + + // check if any wakeup timers have expired and send messages if they have + // this function never returns so it should be called from a new thread + function check_wakeup_timers() { + setInterval(() => { + // check if any timers are set + if (wakeup_schedule.length === 0) { + return; + } + + const now = Date.now(); + + // iterate backward to safely remove expired timers + for (let i = wakeup_schedule.length - 1; i >= 0; i--) { + if (now >= wakeup_schedule[i].time) { + const message = "WAKEUP:" + wakeup_schedule[i].message; + add_text_to_debug("check_wakeup_timers: sending message: " + message); + + // send_to_assistant(message); + send_message(message); + + wakeup_schedule.splice(i, 1); // remove expired timer + } + } + }, 1000); // wait for one second + } + + initialized = true; +} diff --git a/Chat/shared/mavlink.js b/Chat/shared/mavlink.js new file mode 100644 index 00000000..a32d2ab8 --- /dev/null +++ b/Chat/shared/mavlink.js @@ -0,0 +1,10 @@ +export let MAVLink = null; +export let mavlink_ws = null; + +export function setMAVLink(instance) { + MAVLink = instance; +} + +export function setMavlinkWS(ws) { + mavlink_ws = ws; +} diff --git a/Chat/shared/resource_loader.js b/Chat/shared/resource_loader.js new file mode 100644 index 00000000..3470110b --- /dev/null +++ b/Chat/shared/resource_loader.js @@ -0,0 +1,75 @@ +// remote source for instructions, tools, and knowledge files +const GITHUB_BASE = "https://raw.githubusercontent.com/ArduPilot/MAVProxy/master/MAVProxy/modules/mavproxy_chat/assistant_setup/"; + +// list of JSON function definition files +window.JSON_FUNCTION_FILES = [ + "delete_wakeup_timers.json", + "get_all_parameters.json", + "get_available_mavlink_messages.json", + "get_current_datetime.json", + "get_location_plus_dist_at_bearing.json", + "get_location_plus_offset.json", + "get_mavlink_message.json", + "get_mode_mapping.json", + "get_parameter.json", + "get_parameter_description.json", + "get_vehicle_location_and_yaw.json", + "get_vehicle_state.json", + "get_vehicle_type.json", + "get_wakeup_timers.json", + "send_mavlink_command_int.json", + "send_mavlink_set_position_target_global_int.json", + "set_parameter.json", + "set_wakeup_timer.json" +]; + +const KNOWLEDGE_TEXT_FILES = [ + "copter_flightmodes.txt", + "plane_flightmodes.txt", + "rover_modes.txt", + "sub_modes.txt" +]; + +export async function loadTextFile(fileName) { + try { + const url = `${GITHUB_BASE}${fileName}`; + const res = await fetch(url); + if (!res.ok) { + add_text_to_debug(`Failed to fetch text file ${fileName}: ${res.status}`); + return ""; + } + return await res.text(); + } catch (err) { + add_text_to_debug(`Error fetching text file ${fileName}: ${err}`); + return ""; + } +} + +export async function loadJSONFile(fileName) { + try { + const url = `${GITHUB_BASE}${fileName}`; + const res = await fetch(url); + if (!res.ok) { + add_text_to_debug(`Failed to fetch JSON file ${fileName}: ${res.status}`); + return null; + } + return await res.json(); + } catch (err) { + add_text_to_debug(`Error fetching JSON file ${fileName}: ${err}`); + return null; + } +} + + +export async function loadInstructions() { + const base = await loadTextFile("assistant_instructions.txt"); + if (!base) return ""; + let knowledgeConcat = ""; + for (const file of KNOWLEDGE_TEXT_FILES) { + const content = await loadTextFile(file); + if (content) { + knowledgeConcat += `\n\n# ${file}\n${content}`; + } + } + return base + knowledgeConcat; +} \ No newline at end of file diff --git a/Chat/shared/ui.js b/Chat/shared/ui.js new file mode 100644 index 00000000..80fa2766 --- /dev/null +++ b/Chat/shared/ui.js @@ -0,0 +1,48 @@ +export function add_text_to_debug(text) { + const ta = document.getElementById("debugOutput"); + if (!ta) return; + ta.value += (ta.value ? "\n" : "") + String(text); + + // only scroll if toggle is checked + const autoScroll = document.getElementById("autoScrollToggle").checked; + if (autoScroll) { + ta.scrollTop = ta.scrollHeight; + } +} + +let chatMsgCounter = 0; + +export function add_text_to_chat(text, role = "assistant", opts = {}) { + const chatBox = document.getElementById("chatBox"); + const divClass = role === "assistant" ? "assistant-text" : "user-text"; + + if (role === "assistant" && opts.append !== false) { + const last = chatBox.querySelector(`.${divClass}:last-of-type`); + if (last) { + last.textContent += text; + chatBox.scrollTop = chatBox.scrollHeight; + return last.dataset.msgId || null; + } + } + + const el = document.createElement("div"); + el.className = divClass + (opts.muted ? " muted" : ""); + el.textContent = text; + const id = `msg-${++chatMsgCounter}`; + el.dataset.msgId = id; + chatBox.appendChild(el); + chatBox.scrollTop = chatBox.scrollHeight; + return id; +} + + +window.chatBusy = false; + +export function setChatBusy(state) { + window.chatBusy = state + document.getElementById("sendMessageButton").disabled = state + document.getElementById("recordButton").disabled = state + document.getElementById("userInput").disabled = state +} + + diff --git a/Chat/style.css b/Chat/style.css new file mode 100644 index 00000000..5b5e6193 --- /dev/null +++ b/Chat/style.css @@ -0,0 +1,551 @@ +/* CSS Variables for theming */ +:root { + --bg-primary: #F8F9FA; + --bg-secondary: #FFFFFF; + --bg-chat-area: #F1F3F4; + --assistant-msg-bg: #E6EEFA; + --text-primary: #202124; + --text-muted: #70757A; + --border-color: #DADCE0; + --input-bg: #FFFFFF; + --input-border: #DADCE0; + --debug-bg: #E8EAED; + --blue-accent: #4285F4; + --green-accent: #34A853; + --yellow-accent: #FBBC04; + --red-accent: #EA4335; + --blue-hover-light: #F0F6FF; + --pick-assistant-bg: var(--blue-accent); +} + +body.dark-theme { + --bg-primary: #1A1A1A; + --bg-secondary: #202124; + --bg-chat-area: #1A1A1A; + --assistant-msg-bg: #303134; + --text-primary: #E8EAED; + --text-muted: #9AA0A6; + --border-color: #3c4043; + --input-bg: #303134; + --input-border: #404144; + --debug-bg: #1A1A1A; + --blue-hover-light: transparent; + --pick-assistant-bg: linear-gradient(90deg, #4285F4, #34A853); +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-chat-area); +} + +::-webkit-scrollbar-thumb { + background: var(--blue-accent); + border-radius: 4px; + border: 2px solid transparent; + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--blue-accent) 80%, black); +} + +/* Chat message styles */ +.user-text { + align-self: flex-end; + background-color: var(--blue-accent); + color: #FFFFFF; + padding: 10px 16px; + border-radius: 1.25rem 1.25rem 0.25rem 1.25rem; + max-width: 75%; + margin: 4px 0; + font-size: 0.95rem; + line-height: 1.5; + box-shadow: 0 2px 4px rgba(66, 133, 244, 0.2); +} + +.assistant-text { + align-self: flex-start; + background-color: var(--assistant-msg-bg); + color: var(--text-primary); + padding: 10px 16px; + border-radius: 1.25rem 1.25rem 1.25rem 0.25rem; + max-width: 75%; + margin: 4px 0; + font-size: 0.95rem; + line-height: 1.5; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.muted { + opacity: 0.6; + font-style: italic; + color: var(--text-muted); +} + +/* Global styles */ +html, +body { + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + background-color: var(--bg-primary); + font-family: 'Inter', sans-serif; + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html { + font-size: 14px; +} + +@media (min-width: 768px) { + html { + font-size: 16px; + } +} + +@media (min-width: 1024px) { + html { + font-size: 18px; + } +} + +/* Header styling */ +header { + display: none; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 0 0 0.5rem 0.5rem; + flex-shrink: 0; + padding: 1rem; +} + +@media (min-width: 1024px) { + header { + display: flex; + justify-content: space-between; + align-items: center; + } + + header h1 { + flex-grow: 1; + text-align: center; + color: var(--blue-accent); + margin-left: auto; + margin-right: auto; + } +} + +/* Layout styles */ +#appRoot { + flex-direction: column; + padding: 0.5rem; + background-color: var(--bg-primary); +} + +.mobile-chat-full-height { + height: calc(95vh - 64px); + flex-shrink: 0; + padding: 0.75rem; +} + +#chatBox { + height: calc(100% - 140px); + background-color: var(--bg-chat-area); +} + +@media (min-width: 1024px) { + #appRoot { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + overflow-y: hidden; + height: calc(100vh - 64px); + padding: 1.5rem; + } + + .mobile-chat-full-height { + height: 100%; + min-height: auto; + padding: 1rem; + } + + .lg\:col-span-3, + .lg\:col-span-2 { + height: 100%; + overflow-y: auto; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + } +} + +/* Input and button styles */ +.input-row-element { + height: 40px; +} + +/* Mobile styles */ +@media (max-width: 639px) { + .chat-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + } + + .chat-panel-header h2 { + margin-bottom: 0; + } + + #userInput, + #mavlink-connect-url { + padding: 0.5rem 0.75rem; + font-size: 0.95rem; + } + + .input-button-group { + gap: 0.5rem; + width: auto; + flex-shrink: 0; + } + + .input-button-group button { + width: 40px; + padding: 0; + flex-grow: 0; + flex-shrink: 0; + } + + .input-button-group svg { + height: 1.5rem; + width: 1.5rem; + } + + .chat-input-row, + .connect-drone-row { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .connect-drone-row #mavlink-connect-button { + flex-shrink: 0; + width: auto; + padding: 0 1rem; + font-size: 0.9rem; + height: 40px; + } + + #openai_api_key, + #assistantId, + #assistantThreadId, + #sessionId, + #assistantRunStatus { + padding: 0.5rem 0.75rem; + font-size: 0.95rem; + } + + button { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + } + + #openai-connect-button { + padding: 0.75rem 1.25rem; + font-size: 0.95rem; + } + + #toggleApiKeyButton svg { + height: 1.75rem; + width: 1.75rem; + } + + #debugOutput { + font-size: 0.875rem; + } +} + +/* Desktop styles */ +@media (min-width: 640px) { + + #userInput, + #mavlink-connect-url { + padding: 0.75rem 1rem; + font-size: 1rem; + } + + .input-button-group button { + padding: 0.75rem; + } + + .input-button-group svg { + height: 1.5rem; + width: 1.5rem; + } + + .chat-input-row, + .connect-drone-row { + display: flex; + flex-direction: row; + gap: 0.75rem; + } + + .connect-drone-row #mavlink-connect-url { + flex-grow: 1; + width: auto; + padding: 0.625rem 1.25rem; + font-size: 0.875rem; + } + + .connect-drone-row #mavlink-connect-button { + padding: 0.625rem 1.25rem; + font-size: 0.875rem; + height: auto; + } + + #openai_api_key, + #assistantId, + #assistantThreadId, + #sessionId, + #assistantRunStatus { + padding: 0.75rem 1rem; + font-size: 1rem; + } + + #openai-connect-button { + padding: 0.625rem 1.25rem; + font-size: 0.875rem; + } + + button { + padding-top: 0.625rem; + padding-bottom: 0.625rem; + font-size: 0.875rem; + } + + #toggleApiKeyButton svg { + height: 1.75rem; + width: 1.75rem; + } + + #debugOutput { + font-size: 0.875rem; + } +} + +/* Component-specific styles */ +#modeChooser>div { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); +} + +#modeChooser h2, +.chat-panel-header h2, +.side-panel-mobile-styling h2 { + color: var(--blue-accent); +} + +#modeChooser p { + color: var(--text-muted); +} + +#modeChooser #pickAssistant { + background-color: transparent; + border: 1px solid var(--blue-accent); + color: var(--blue-accent); +} + +#modeChooser #pickAssistant:hover { + background-color: var(--blue-hover-light); + color: var(--blue-accent); + border-color: var(--blue-accent); +} + +#modeChooser #pickRealtime { + background: var(--pick-assistant-bg); + color: white; +} + +#modeChooser #pickRealtime:hover { + opacity: 0.9; +} + +#modeChooser .text-xs div { + color: var(--blue-accent); +} + +#modeChooser .text-xs ul { + color: var(--text-primary); +} + +#sessionTimer { + border: 1px solid var(--border-color); + background-color: var(--bg-secondary); +} + +#sessionTimer svg { + color: var(--green-accent); +} + +#sessionTimer span { + color: var(--text-primary); +} + +.chat-input-row, +.connect-drone-row { + border-top: 1px solid var(--border-color); + padding-top: 1rem; +} + +/* Input fields */ +#userInput, +#mavlink-connect-url, +#openai_api_key, +#assistantId, +#assistantThreadId, +#sessionId, +#assistantRunStatus, +#debugOutput { + background-color: var(--input-bg); + border: 1px solid var(--input-border); + color: var(--text-primary); +} + +#userInput::placeholder, +#mavlink-connect-url::placeholder { + color: var(--text-muted); +} + +#userInput:focus, +#mavlink-connect-url:focus, +#openai_api_key:focus, +#debugOutput:focus { + border-color: var(--blue-accent); + box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.4); + background-color: var(--input-bg); +} + +#openai_api_key, +#assistantId, +#assistantThreadId, +#sessionId, +#assistantRunStatus { + background-color: var(--bg-chat-area); +} + +#debugOutput { + background-color: var(--debug-bg); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 600; + border-radius: .65rem; + padding: .55rem .9rem; + gap: .35rem; + line-height: 1; + border: 1px solid transparent; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + background: var(--blue-accent); + color: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, .08), 0 1px 1px rgba(0, 0, 0, .06); + transition: background .18s, box-shadow .18s, transform .15s; +} + +.btn:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, .15); +} + +.btn:active { + transform: translateY(1px); + box-shadow: 0 1px 2px rgba(0, 0, 0, .12); +} + +.btn:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(66, 133, 244, .35); +} + +.btn[disabled], +.btn.disabled { + opacity: .55; + cursor: not-allowed; + pointer-events: none; +} + +.btn-small { + padding: .4rem .7rem; + font-size: .8rem; +} + +.btn-large { + padding: .8rem 1.25rem; + font-size: 1rem; +} + +/* Color variants */ +.btn-blue { + background: var(--blue-accent); +} + +.btn-blue:hover { + background: color-mix(in srgb, var(--blue-accent) 85%, black); +} + +.btn-green { + background: var(--green-accent); +} + +.btn-green:hover { + background: color-mix(in srgb, var(--green-accent) 85%, black); +} + +.btn-yellow { + background: var(--yellow-accent); + color: var(--text-primary); +} + +.btn-yellow:hover { + background: color-mix(in srgb, var(--yellow-accent) 85%, black); +} + +.btn-red { + background: var(--red-accent); +} + +.btn-red:hover { + background: color-mix(in srgb, var(--red-accent) 85%, black); +} + +/* Subtle outline style */ +.btn-outline { + background: transparent; + border: 1px solid currentColor; + color: var(--blue-accent); +} + +.btn-outline:hover { + background: var(--blue-hover-light); +} + +.side-panel-mobile-styling .p-4.border-b { + border-color: var(--border-color); +} + +label { + color: var(--text-primary); +} \ No newline at end of file diff --git a/Chat/toggle.js b/Chat/toggle.js new file mode 100644 index 00000000..4951433b --- /dev/null +++ b/Chat/toggle.js @@ -0,0 +1,110 @@ +// Toggle Script (Theme and API key visibility) + +document.addEventListener('DOMContentLoaded', () => { + const body = document.body; + // Use distinct IDs for desktop and mobile theme buttons/icons + const themeToggleButtonDesktop = document.getElementById('themeToggleButtonDesktop'); + const themeIconDesktop = document.getElementById('themeIconDesktop'); + const themeToggleButtonMobile = document.getElementById('themeToggleButtonMobile'); + const themeIconMobile = document.getElementById('themeIconMobile'); + + + // Function to set the theme + function setTheme(theme) { + if (theme === 'dark') { + body.classList.add('dark-theme'); + localStorage.setItem('theme', 'dark'); + + // Update desktop theme button + if (themeIconDesktop) { + themeIconDesktop.innerHTML = ` + + `; + themeToggleButtonDesktop.classList.remove('bg-gray-200', 'text-gray-800', 'focus:ring-blue-500'); + themeToggleButtonDesktop.classList.add('bg-gray-700', 'text-gray-100', 'focus:ring-gray-400'); + } + + // Update mobile theme button + if (themeIconMobile) { + themeIconMobile.innerHTML = ` + + `; + themeToggleButtonMobile.classList.remove('bg-gray-200', 'text-gray-800', 'focus:ring-blue-500'); + themeToggleButtonMobile.classList.add('bg-gray-700', 'text-gray-100', 'focus:ring-gray-400'); + } + + } else { + body.classList.remove('dark-theme'); + localStorage.setItem('theme', 'light'); + + // Update desktop theme button + if (themeIconDesktop) { + themeIconDesktop.innerHTML = ` + + `; + themeToggleButtonDesktop.classList.remove('bg-gray-700', 'text-gray-100', 'focus:ring-gray-400'); + themeToggleButtonDesktop.classList.add('bg-gray-200', 'text-gray-800', 'focus:ring-blue-500'); + } + + // Update mobile theme button + if (themeIconMobile) { + themeIconMobile.innerHTML = ` + + `; + themeToggleButtonMobile.classList.remove('bg-gray-700', 'text-gray-100', 'focus:ring-gray-400'); + themeToggleButtonMobile.classList.add('bg-gray-200', 'text-gray-800', 'focus:ring-blue-500'); + } + } + } + + // Check for saved theme preference on load + const savedTheme = localStorage.getItem('theme') || 'light'; // Default to light if no preference + setTheme(savedTheme); + + // Add event listeners to both buttons if they exist + if (themeToggleButtonDesktop) { + themeToggleButtonDesktop.addEventListener('click', () => { + const currentTheme = body.classList.contains('dark-theme') ? 'dark' : 'light'; + setTheme(currentTheme === 'light' ? 'dark' : 'light'); + }); + } + + if (themeToggleButtonMobile) { + themeToggleButtonMobile.addEventListener('click', () => { + const currentTheme = body.classList.contains('dark-theme') ? 'dark' : 'light'; + setTheme(currentTheme === 'light' ? 'dark' : 'light'); + }); + } + + + // show/hide the OpenAI API key field + function toggle_openai_api_key_visibility() { + let openai_key_input = document.getElementById("openai_api_key"); + const eyeIconContainer = document.getElementById("eyeIcon"); // Get reference to the SVG element + // Eye Icon (Open) + const eyeOpenSVGPath = ` + + + `; + + // Eye-Slash Icon (Closed) + const eyeClosedSVGPath = ` + + + `; + + if (openai_key_input.type === "password") { + openai_key_input.type = "text"; + // Change icon to 'eye open' + eyeIconContainer.innerHTML = eyeOpenSVGPath; + } else { + openai_key_input.type = "password"; + // Change icon to 'eye slash' + eyeIconContainer.innerHTML = eyeClosedSVGPath; + } + } + + // listener for toggle api key visibility button click + document.getElementById("toggleApiKeyButton").addEventListener("click", toggle_openai_api_key_visibility); + +}); diff --git a/Dev/index.html b/Dev/index.html index 1de91698..c0623347 100644 --- a/Dev/index.html +++ b/Dev/index.html @@ -108,6 +108,20 @@

Thrust Expo

+ + + + + + +

Chat

+ OpenAI Chat module + Use an AI chat to control your vehicle + + + + + @@ -116,6 +130,7 @@

Thrust Expo

AI Log Analyzer

AI Log Analyzer module Use an AI agent to analyze your logs file + diff --git a/images/Chat_Icon.png b/images/Chat_Icon.png new file mode 100644 index 00000000..208bae54 Binary files /dev/null and b/images/Chat_Icon.png differ diff --git a/modules/MAVLink/local_modules/README.md b/modules/MAVLink/local_modules/README.md new file mode 100644 index 00000000..ad5a1382 --- /dev/null +++ b/modules/MAVLink/local_modules/README.md @@ -0,0 +1,6 @@ +This folder is a locally modified copy of some Node/npm packages 'jspack' and 'long'. we have copied them here and tweaked them to be compatible with our needs, please see their respective README.md file for their original info, which we have not changed. + +This README.md serves to make you aware that these two packages as stored here in the 'jspack' and 'long' folders ARE MODIFIED from the originals. +By placing this statement here, and putting a notice in long.js as well, we feel are in compliance with the LICENSE file of 'long' , which requires us to tell you they are modified. + +We have included their original license files, in compliance with them, as both license/s permit distribution of derived works in source and/or binary form. diff --git a/modules/MAVLink/local_modules/jspack/.npmignore b/modules/MAVLink/local_modules/jspack/.npmignore new file mode 100644 index 00000000..be1ea81a --- /dev/null +++ b/modules/MAVLink/local_modules/jspack/.npmignore @@ -0,0 +1,19 @@ +lib-cov +*.seed +*.log +*.csv +*.dat +*.out +*.pid +*.gz +*.pyc + +pids +logs +results + +npm-debug.log +node_modules + +**~ +**.swp diff --git a/modules/MAVLink/local_modules/jspack/LICENSE b/modules/MAVLink/local_modules/jspack/LICENSE new file mode 100644 index 00000000..d646dd72 --- /dev/null +++ b/modules/MAVLink/local_modules/jspack/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2008, Fair Oaks Labs, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are +permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list + of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or other + materials provided with the distribution. + + * Neither the name of Fair Oaks Labs, Inc. nor the names of its contributors may be + used to endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/modules/MAVLink/local_modules/jspack/README.md b/modules/MAVLink/local_modules/jspack/README.md new file mode 100644 index 00000000..fee0cdc3 --- /dev/null +++ b/modules/MAVLink/local_modules/jspack/README.md @@ -0,0 +1,147 @@ +jspack - library to pack primitives to octet arrays +==================================================== + +[![Build status](https://travis-ci.org/birchroad/node-jspack.svg?branch=master)](https://travis-ci.org/birchroad/node-jspack) + +## Disclaimer +The jspack module and documentation are essentially ports of the +Python struct module and documentation, with such changes as were necessary. The port was originally made by Fair Oaks Labs, Inc. and published at http://code.google.com/p/jspack/ +If any Python people are miffed that their documentation got ripped off, let me know, +and I'll gladly revise them. + +This module performs conversions between JavaScript values and C structs +represented as octet arrays (i.e. JavaScript arrays of integral numbers +between 0 and 255, inclusive). It uses format strings (explained below) as +compact descriptions of the layout of the C structs and the intended conversion +to/from JavaScript values. This can be used to handle binary data stored in +files, or received from network connections or other sources. + +## Install + npm install jspack + +## Reference + +The module defines the following functions: + +### Unpack(fmt, a, p) +Return an array containing values unpacked from the octet array a, +beginning at position p, according to the supplied format string. If there +are more octets in a than required by the format string, the excess is +ignored. If there are fewer octets than required, Unpack() will return +undefined. If no value is supplied for the p argument, zero is assumed. + +### PackTo(fmt, a, p, values) +Pack and store the values array into the supplied octet array a, beginning +at position p. If there are more values supplied than are specified in the +format string, the excess is ignored. If there are fewer values supplied, +PackTo() will return false. If there is insufficient space in a to store +the packed values, PackTo() will return false. On success, PackTo() returns +the a argument. If any value is of an inappropriate type, the results are +undefined. + +### Pack(fmt, values) +Return an octet array containing the packed values array. If there are +more values supplied than are specified in the format string, the excess is +ignored. If there are fewer values supplied, Pack() will return false. If +any value is of an inappropriate type, the results are undefined. + +### CalcLength(fmt) +Return the number of octets required to store the given format string. + + +## Formats +Format characters have the following meanings; the conversion between C and +JavaScript values should be obvious given their types: + + Format | C Type | JavaScript Type | Size (octets) | Notes + ------------------------------------------------------------------- + A | char[] | Array | Length | (1) + x | pad byte | N/A | 1 | + c | char | string (length 1) | 1 | (2) + b | signed char | number | 1 | (3) + B | unsigned char | number | 1 | (3) + h | signed short | number | 2 | (3) + H | unsigned short | number | 2 | (3) + i | signed int | number | 4 | (3) + I | unsigned int | number | 4 | (3) + l | signed long | number | 4 | (3) + L | unsigned long | number | 4 | (3) + q | signed long | number | 8 | (6) + Q | unsigned long | number | 8 | (6) + s | char[] | string | Length | (2) + f | float | number | 4 | (4) + d | double | number | 8 | (5) + +*Notes:* + + **(1)** The "A" code simply returns a slice of the source octet array. This is + primarily useful when a data structure contains bytes which are subject to + multiple interpretations (e.g. unions), and the data structure is being + decoded in multiple passes. + + **(2)** The "c" and "s" codes handle strings with codepoints between 0 and 255, + inclusive. The data are not bounds-checked, so strings containing characters + with codepoints outside this range will encode to "octet" arrays that contain + values outside the range of an octet. Furthermore, since these codes decode + octet arrays by assuming the octets represent UNICODE codepoints, they may + not "correctly" decode bytes in the range 128-255, since that range is subject + to multiple interpretations. Caveat coder! + + **(3)** The 8 "integer" codes clip their encoded values to the minima and maxmima + of their respective types: If you invoke Struct.Pack('b', [-129]), for + instance, the result will be [128], which is the octet encoding of -128, + which is the minima of a signed char. Similarly, Struct.Pack('h', [-32769]) + returns [128, 0]. Fractions are truncated. + + **(4)** Since JavaScript doesn't natively support 32-bit floats, whenever a float + is stored, the source JavaScript number must be rounded. This module applies + correct rounding during this process. Numbers with magnitude greater than or + equal to 2^128-2^103 round to either positive or negative Infinity. The + rounding algorithm assumes that JavaScript is using exactly 64 bits of + floating point precision; 128-bit floating point will result in subtle errors. + + **(5)** This module assumes that JavaScript is using 64 bits of floating point + precision, so the "d" code performs no rounding. 128-bit floating point will + cause the "d" code to simply truncate significands to 52 bits. + + **(6)** Since 64bit longs cannot be represented by numbers JavaScript, this version of + jspack will process longs as arrays in the form: ```[lowBits, hightBits]```. The + decoded long array contains a third element, the unsigned flag, which is ```false``` for signed + and ```true``` for unsigned values. + This representation is similar to what [Long.js](https://github.com/dcodeIO/Long.js), and + therefore the [Google Closure Libaray](https://github.com/google/closure-library), uses. + See [test/int64.js](test/int64.js) for examples how to work with Long.js. + +A format character may be preceded by an integral repeat count. For example, +the format string "4h" means exactly the same thing as "hhhh". + +Whitespace characters between formats are ignored; a count and its format must +not be separated by whitespace, however. + +For the "A" format character, the count is interpreted as the size of the +array, not a repeat count as for the other format characters; for example, "10A" +means a single 10-octet array. When packing, the Array is truncated or padded +with 0 bytes as appropriate to make it conform to the specified length. When +unpacking, the resulting Array always has exactly the specified number of bytes. +As a special case, "0A" means a single, empty Array. + +For the "s" format character, the count is interpreted as the size of the +string, not a repeat count as for the other format characters; for example, +"10s" means a single 10-byte string, while "10c" means 10 characters. When +packing, the string is truncated or padded with 0 bytes as appropriate to make +it conform to the specified length. When unpacking, the resulting string always +has exactly the specified number of bytes. As a special case, "0s" means a +single, empty string (while "0c" means 0 characters). + + +By default, C numbers are represented in network (or big-endian) byte order. +Alternatively, the first character of the format string can be used to indicate +byte order of the packed data, according to the following table: + + Character | Byte Order + ---------------------------------- + < | little-endian + > | big-endian + ! | network (= big-endian) + +If the first character is not one of these, "!" is assumed. diff --git a/modules/MAVLink/local_modules/jspack/jspack.js b/modules/MAVLink/local_modules/jspack/jspack.js new file mode 100644 index 00000000..20095fed --- /dev/null +++ b/modules/MAVLink/local_modules/jspack/jspack.js @@ -0,0 +1,891 @@ +/** + * @license + + Copyright © 2008 Fair Oaks Labs, Inc. + All rights reserved. + + This file is Modified from the original, by buzz 2020: + - ran thru http://www.jsnice.org/ and manually renamed the variables to be clearer + - added optionally enabled debugging/verbose/printfs throughout + - bugfixes and integration so it now passes our mavlink.js testsuite/s + - please see README.md in the upper level folder. +*/ +'use strict'; + +//var Long = require('long'); + +let DEBUG = false; + +/** + * @return {undefined} + */ +function JSPack() { + var el; + /** @type {boolean} */ + var booleanIsBigEndian = false; + var m = this; + + + /** + * @param {!Object} octet_array_a + * @param {number} offset_p + * @param {number} len + * @return {?} + */ + //Raw byte arrays + // m._DeArray = function(octet_array_a, offset_p, len) { + // if (DEBUG) console.log("zzz1"); + // return [octet_array_a.slice(offset_p, offset_p + len)]; + //}; + + /** + * @param {!Array} to_octet_array_a + * @param {number} offset_p + * @param {number} len + * @param {!NodeList} from_array_v + * @return {undefined} + */ + // m._EnArray = function(to_octet_array_a, offset_p, len, from_array_v) { + // if (DEBUG) console.log("zzz2"); + /** @type {number} */ + // var i = 0; + // for (; i < len; to_octet_array_a[offset_p + i] = from_array_v[i] ? from_array_v[i] : 0, i++) { + // } + //}; + + + /** + * @param {!Object} octet_array_a + * @param {number} offset_p + * @return {?} + */ + // ASCII characters + m._DeChar = function(octet_array_a, offset_p) { + if (DEBUG) console.log("zzz3"); + return String.fromCharCode(octet_array_a[offset_p]); + }; + /** + * @param {!Array} to_octet_array_a + * @param {number} offset_p + * @param {string} from_str_array_v + * @return {undefined} + */ + // m._EnChar = function(to_octet_array_a, offset_p, from_str_array_v) { + // if (DEBUG) console.log("zzz4"); + // /** @type {number} */ + // to_octet_array_a[offset_p] = from_str_array_v.charCodeAt(0); + // }; + + + /** + * @param {!Object} octet_array_a + * @param {number} offset_p + * @return {?} + */ + //Little-endian (un)signed N-byte integers + m._DeInt = function(octet_array_a, offset_p) { + if (DEBUG) console.log("zzz5"); + /** @type {number} */ + var lsb = booleanIsBigEndian ? el.len - 1 : 0; + /** @type {number} */ + var nsb = booleanIsBigEndian ? -1 : 1; + /** @type {number} */ + var stop = lsb + nsb * el.len; + var rv; + var i; + var f; + /** @type {number} */ + rv = 0; + /** @type {number} */ + i = lsb; + /** @type {number} */ + f = 1; + for (; i != stop; rv = rv + octet_array_a[offset_p + i] * f, i = i + nsb, f = f * 256) { + } + if (el.bSigned && rv & Math.pow(2, el.len * 8 - 1)) { + /** @type {number} */ + rv = rv - Math.pow(2, el.len * 8); + } + return rv; + }; + + + /** + * @param {!Array} octet_array_a + * @param {number} offset_p + * @param {number} val + * @return {undefined} + */ + m._EnInt = function(octet_array_a, offset_p, val) { + if (DEBUG) console.log("chunk-from: "+val); + /** @type {number} */ + var lsb = booleanIsBigEndian ? el.len - 1 : 0; + /** @type {number} */ + var nsb = booleanIsBigEndian ? -1 : 1; + /** @type {number} */ + var stop = lsb + nsb * el.len; + var i; + // range limit: + if (val < el.min ) { + val = el.min; + console.log("value limited to MIN:"+val); + } + if (val > el.max ) { + val = el.max; + console.log("value limited to MAX:"+val); + } + /** @type {number} */ + i = lsb; + if (DEBUG) console.log("booleanIsBigEndian:"+booleanIsBigEndian); + if (DEBUG) console.log("el.len:"+el.len); + if (DEBUG) console.log("lsb:"+lsb); + if (DEBUG) console.log("nsb:"+nsb); + if (DEBUG) console.log("i:"+i); + if (DEBUG) console.log("stop:"+stop); + for (; i != stop; ) { + + var to = JSON.stringify(val&255); + if (DEBUG) console.log("chunk as bytes: "+to); + + octet_array_a[offset_p + i] = val & 255; + i = i + nsb; + val = val >> 8; + + + } + }; + + + /** + * @param {!Object} octet_array_a + * @param {number} offset_p + * @param {number} len + * @return {?} + */ + // ASCII character strings + m._DeString = function(octet_array_a, offset_p, len) { + if (DEBUG) console.log("zzz7"); + /** @type {!Array} */ + var retval = new Array(len); + /** @type {number} */ + var i = 0; + for (; i < len; retval[i] = String.fromCharCode(octet_array_a[offset_p + i]), i++) { + } + return retval.join(""); + }; + /** + * @param {!Array} octet_array_a + * @param {number} offset_p + * @param {number} len + * @param {string} strval + * @return {undefined} + */ + m._EnString = function(octet_array_a, offset_p, len, strval) { + if (DEBUG) console.log("zzz8"); + var t; + /** @type {number} */ + if ( DEBUG ) console.log("strencode before: "+octet_array_a+"\np:"+offset_p+" len:"+len+" strval:"+strval) + var i = 0; + //if (DEBUG) console.log("strval:"+strval); +//console.trace("Here I am!") + + // we all strings to be passed in as a string of characters, or a an array or buffer of them is ok too + + if (typeof strval.charCodeAt === "function") { + for (; i < len; octet_array_a[offset_p + i] = (t = strval.charCodeAt(i)) ? t : 0, i++) { + if ( t > 255 ) console.log("ERROR ERROR ERROR ERROR ERROR ERROR - It seems u passed unicode/utf-8/etc to jspack, not 8 bit ascii. please use .toString('binary'); not .toString();"); + } + if ( DEBUG ) console.log("strencode from CHAR-string."); + + } else if (Array.isArray(strval)) { + for (; i < len; octet_array_a[offset_p + i] = (t = strval[i]) ? t : 0, i++) { + // referring directly to 't' inside this loop is bad, seems delayed by an iteration, but strval[i] is ok. + if ( strval[i] > 255 ) console.log("ERROR ERROR ERROR ERROR ERROR ERROR - It seems u passed unicode/utf-8/etc, or array data with values > 255, to jspack, not 8 bit ascii.\n(bad Array data)"+strval[i]); + } + if ( DEBUG ) console.log("strencode from ARRAY."); + + } else if (Buffer.isBuffer(strval)) { + for (; i < len; octet_array_a[offset_p + i] = (t = strval[i]) ? t : 0, i++) { + if ( strval[i] > 255 ) console.log("ERROR ERROR ERROR ERROR ERROR ERROR - It seems u passed unicode/utf-8/etc to jspack, not 8 bit ascii. \n(bad Buffer data)"+strval[i]); + } + if ( DEBUG ) console.log("strencode from Buffer."); + + } else { + console.log("ERROR encoding string _EnString: array:"+octet_array_a+" p:"+offset_p+" len:"+len+" strval:"+JSON.stringify(strval)) +} + }; + + + /** + * @param {!Object} octet_array_a + * @param {number} offset_p + * @return {?} + */ + // Little-endian N-bit IEEE 754 floating point + m._De754 = function(octet_array_a, offset_p) { + if (DEBUG) console.log("zzz9"); + var bool_s; + var exponent; + var mantissa; + var i; + var d; + var nBits; + var mantissaLen; + var exponentLen; + var eBias; + var eMax; + mantissaLen = el.mLen; + /** @type {number} */ + exponentLen = el.len * 8 - el.mLen - 1; + /** @type {number} */ + eMax = (1 << exponentLen) - 1; + /** @type {number} */ + eBias = eMax >> 1; + /** @type {number} */ + i = booleanIsBigEndian ? 0 : el.len - 1; + /** @type {number} */ + d = booleanIsBigEndian ? 1 : -1; + bool_s = octet_array_a[offset_p + i]; + /** @type {number} */ + i = i + d; + /** @type {number} */ + nBits = -7; + /** @type {number} */ + exponent = bool_s & (1 << -nBits) - 1; + /** @type {number} */ + bool_s = bool_s >> -nBits; + /** @type {number} */ + nBits = nBits + exponentLen; + for (; nBits > 0; exponent = exponent * 256 + octet_array_a[offset_p + i], i = i + d, nBits = nBits - 8) { + } + /** @type {number} */ + mantissa = exponent & (1 << -nBits) - 1; + /** @type {number} */ + exponent = exponent >> -nBits; + nBits = nBits + mantissaLen; + for (; nBits > 0; mantissa = mantissa * 256 + octet_array_a[offset_p + i], i = i + d, nBits = nBits - 8) { + } + switch(exponent) { + case 0: + /** @type {number} */ + // Zero, or denormalized number + exponent = 1 - eBias; + break; + case eMax: + // NaN, or +/-Infinity + return mantissa ? NaN : (bool_s ? -1 : 1) * Infinity; + default: + // Normalized number + mantissa = mantissa + Math.pow(2, mantissaLen); + /** @type {number} */ + exponent = exponent - eBias; + break; + } + return (bool_s ? -1 : 1) * mantissa * Math.pow(2, exponent - mantissaLen); + }; + /** + * @param {!Array} octet_array_a + * @param {number} offset_p + * @param {number} v + * @return {undefined} + */ + m._En754 = function(octet_array_a, offset_p, v) { + if (DEBUG) console.log("zzz_10"); + var bool_s; + var exponent; + var mantissa; + var i; + var d; + var c; + var mantissaLen; + var exponentLen; + var eBias; + var eMax; + mantissaLen = el.mLen; + /** @type {number} */ + exponentLen = el.len * 8 - el.mLen - 1; + /** @type {number} */ + eMax = (1 << exponentLen) - 1; + /** @type {number} */ + eBias = eMax >> 1; + /** @type {number} */ + bool_s = v < 0 ? 1 : 0; + /** @type {number} */ + v = Math.abs(v); + if (isNaN(v) || v == Infinity) { + /** @type {number} */ + mantissa = isNaN(v) ? 1 : 0; + /** @type {number} */ + exponent = eMax; + } else { + /** @type {number} */ + exponent = Math.floor(Math.log(v) / Math.LN2);// Calculate log2 of the value + if (v * (c = Math.pow(2, -exponent)) < 1) { // Math.log() isn't 100% reliable + exponent--; + /** @type {number} */ + c = c * 2; + } + // Round by adding 1/2 the significand's LSD + if (exponent + eBias >= 1) { + /** @type {number} */ + v = v + el.rt / c; // Normalized: mLen significand digits + } else { + /** @type {number} */ + v = v + el.rt * Math.pow(2, 1 - eBias);// Denormalized: <= mLen significand digits + } + if (v * c >= 2) { + exponent++; + /** @type {number} */ + c = c / 2; // Rounding can increment the exponent + } + if (exponent + eBias >= eMax) { + // Overflow + /** @type {number} */ + mantissa = 0; + /** @type {number} */ + exponent = eMax; + } else { + if (exponent + eBias >= 1) { + // Normalized - term order matters, as Math.pow(2, 52-e) and v*Math.pow(2, 52) can overflow + /** @type {number} */ + mantissa = (v * c - 1) * Math.pow(2, mantissaLen); + /** @type {number} */ + exponent = exponent + eBias; + } else { + // Denormalized - also catches the '0' case, somewhat by chance + /** @type {number} */ + mantissa = v * Math.pow(2, eBias - 1) * Math.pow(2, mantissaLen); + /** @type {number} */ + exponent = 0; + } + } + } + /** @type {number} */ + i = booleanIsBigEndian ? el.len - 1 : 0; + /** @type {number} */ + d = booleanIsBigEndian ? -1 : 1; + for (; mantissaLen >= 8; octet_array_a[offset_p + i] = mantissa & 255, i = i + d, mantissa = mantissa / 256, mantissaLen = mantissaLen - 8) { + } + /** @type {number} */ + exponent = exponent << mantissaLen | mantissa; + exponentLen = exponentLen + mantissaLen; + for (; exponentLen > 0; octet_array_a[offset_p + i] = exponent & 255, i = i + d, exponent = exponent / 256, exponentLen = exponentLen - 8) { + } + octet_array_a[offset_p + i - d] |= bool_s * 128; + }; + + + /** + * @param {!Object} octet_array_a + * @param {number} offset_p + * @return {?} + */ + // Convert int64 to array with 3 elements: [lowBits, highBits, unsignedFlag] + // '>>>' trick to convert signed 32bit int to unsigned int (because << always results in a signed 32bit int) + m._DeInt64 = function(octet_array_a, offset_p) { + if (DEBUG) console.log("zzz_11"); + /** @type {number} */ + var lsb = booleanIsBigEndian ? 0 : 7; + /** @type {number} */ + var nsb = booleanIsBigEndian ? 1 : -1; + /** @type {number} */ + var stop = lsb + nsb * 8; + /** @type {!Array} */ + var nextIdLookup = [0, 0, !el.bSigned]; + var i; + var f; + var indexLookupKey; + /** @type {number} */ + i = lsb; + /** @type {number} */ + indexLookupKey = 1; + /** @type {number} */ + f = 0; + for (; i != stop; nextIdLookup[indexLookupKey] = (nextIdLookup[indexLookupKey] << 8 >>> 0) + octet_array_a[offset_p + i], i = i + nsb, f++, indexLookupKey = f < 4 ? 1 : 0) { + + if ( DEBUG ) console.log("jsPacking int64:"+octet_array_a[offset_p + i]); + + } + return nextIdLookup; + }; + /** + * @param {!Array} octet_array_a + * @param {number} offset_p + * @param {!Object} v + * @return {undefined} + */ + m._EnInt64 = function(octet_array_a, offset_p, v) { + + if (v.length != 2) { //todo put this error back + console.log("ERROR ERROR: jspack needs an array of at least length TWO to pack an int64 "+v+' len:'+v.length); + } +// if (DEBUG) console.log("zzz_12 v:"+v); + /** @type {number} */ + var lsb = booleanIsBigEndian ? 0 : 7; + /** @type {number} */ + var nsb = booleanIsBigEndian ? 1 : -1; + /** @type {number} */ + var stop = lsb + nsb * 8; + var i; + var f; + var j; + var shift; + /** @type {number} */ + i = lsb; + /** @type {number} */ + j = 1; + /** @type {number} */ + f = 0; + /** @type {number} */ + shift = 24; + + for (; i != stop; octet_array_a[offset_p + i] = v[j] >> shift & 255, i = i + nsb, f++, j = f < 4 ? 1 : 0, shift = 24 - 8 * (f % 4)) { + var x = v[j] >> shift & 255 ; + var vj = v[j]; + + if ( DEBUG ) console.log('js qqqq vj:'+vj+' j:'+j+' x:'+x+' a:'+octet_array_a+' i:'+i+" offset_p:"+offset_p+" v:"+v); + } + }; + + + + // Class data + /** @type {string} */ + m._sPattern = "(\\d+)?([AxcbBhHsfdiIlLqQ])"; + + m._lenLut = {'A':1, 'x':1, 'c':1, 'b':1, 'B':1, 'h':2, 'H':2, 's':1, 'f':4, 'd':8, 'i':4, 'I':4, 'l':4, 'L':4, 'q':8, 'Q':8}; + + m._elLookUpTable = { 'A': {en:m._EnArray, de:m._DeArray}, + 's': {en:m._EnString, de:m._DeString}, + 'c': {en:m._EnChar, de:m._DeChar}, + 'b': {en:m._EnInt, de:m._DeInt, len:1, bSigned:true, min:-Math.pow(2, 7), max:Math.pow(2, 7)-1}, + 'B': {en:m._EnInt, de:m._DeInt, len:1, bSigned:false, min:0, max:Math.pow(2, 8)-1}, + 'h': {en:m._EnInt, de:m._DeInt, len:2, bSigned:true, min:-Math.pow(2, 15), max:Math.pow(2, 15)-1}, + 'H': {en:m._EnInt, de:m._DeInt, len:2, bSigned:false, min:0, max:Math.pow(2, 16)-1}, + 'i': {en:m._EnInt, de:m._DeInt, len:4, bSigned:true, min:-Math.pow(2, 31), max:Math.pow(2, 31)-1}, + 'I': {en:m._EnInt, de:m._DeInt, len:4, bSigned:false, min:0, max:Math.pow(2, 32)-1}, + 'l': {en:m._EnInt, de:m._DeInt, len:4, bSigned:true, min:-Math.pow(2, 31), max:Math.pow(2, 31)-1}, + 'L': {en:m._EnInt, de:m._DeInt, len:4, bSigned:false, min:0, max:Math.pow(2, 32)-1}, + 'f': {en:m._En754, de:m._De754, len:4, mLen:23, rt:Math.pow(2, -24)-Math.pow(2, -77)}, + 'd': {en:m._En754, de:m._De754, len:8, mLen:52, rt:0}, + 'q': {en:m._EnInt64, de:m._DeInt64, bSigned:true, len:8 }, // 64bit fields need 8 bytes.. + 'Q': {en:m._EnInt64, de:m._DeInt64, bSigned:false, len:8 }}; // quirk of longs is they come in with a length of 2 in an array + + + /** + * @param {number} num_elements_n + * @param {number} size_s + * @param {!Object} octet_array_a + * @param {number} offset_p + * @return {?} + */ + // Unpack a series of n elements of size s from array a at offset p with fxn + m._UnpackSeries = function(num_elements_n, size_s, octet_array_a, offset_p) { + if (DEBUG) console.log("zzz_13"); + var fxn = el.de; + /** @type {!Array} */ + var rv = []; + /** @type {number} */ + var o = 0; + for (; o < num_elements_n; rv.push(fxn(octet_array_a, offset_p + o * size_s)), o++) { + } + return rv; + }; + /** + * @param {number} num_elements_n + * @param {number} size_s + * @param {!Array} to_octet_array_a + * @param {number} array_a_offset_p + * @param {(Array|NodeList|null)} from_array_v + * @param {number} array_v_offset_i + * @return {undefined} + */ + // Pack a series of n elements of size s from array v at offset i to array a at offset p with fxn + + m._PackSeries = function(num_elements_n, size_s, to_octet_array_a, array_a_offset_p, from_array_v, array_v_offset_i) { + if (DEBUG) console.log("pack-series: "); + + + if ( DEBUG ) console.log('js before 0:'+0+' num_elements_n:'+num_elements_n+' size_s:'+size_s+' to_a:'+to_octet_array_a+' i:'+array_v_offset_i+" offset_p:"+array_a_offset_p+" v:"+from_array_v); + var fxn = el.en; + /** @type {number} */ + var o = 0; + for (; o < num_elements_n; o++) { + //if (DEBUG) console.log("14 called fxn with o:"+o); + var z = from_array_v[array_v_offset_i + o]; + var to = JSON.stringify(z); + var too = JSON.stringify(from_array_v); + if (DEBUG) console.log('js pre-ffff z:'+z+' to:'+to+' too:'+too+''); + // handle flattened arrays - non-array things don't have a .length + try { + if (z.length == undefined ) { + //from_array_v = [ from_array_v ] ; + if (DEBUG) console.log('Z FIX'); + }} catch (e){} + var z = from_array_v[array_v_offset_i + o]; + var to = JSON.stringify(z); + var too = JSON.stringify(from_array_v); + + // if we only have one thing to back and its got an 8 byte target len ( it's a 64bit long), and length of source array is 2 ( low and high bits ) + // we treat it as a singular thing... we use this for Q type, which gets passed in as [lowBits, hightBits] + if (( num_elements_n == 1 ) && (size_s == 8) && (from_array_v.length == 2) ) { + z = from_array_v; + if (DEBUG) console.log("js handling Q 64bit array"); + } + + + if (DEBUG) console.log('js partial z:'+z+' to:'+to+' too:'+too+' num_elements_n:'+num_elements_n+' size_s:'+size_s+' to_a:'+to_octet_array_a+' v_offset_i:'+array_v_offset_i+" a_offset_p:"+array_a_offset_p+" from_v:"+from_array_v); + + fxn(to_octet_array_a, array_a_offset_p + o * size_s, z); + + } + if (DEBUG) console.log('js after to_a:'+to_octet_array_a); + }; + + + /** + * @param {string} fmt + * @param {!Object} octet_array_a + * @param {number} offset_p + * @return {?} + */ + // Unpack the octet array a, beginning at offset p, according to the fmt string + m.Unpack = function(fmt, octet_array_a, offset_p) { + if (DEBUG) console.log("zzz_15"); + /** @type {boolean} */ + // Set the private bBE flag based on the format string - assume big-endianness + booleanIsBigEndian = fmt.charAt(0) != "<"; + /** @type {number} */ + offset_p = offset_p ? offset_p : 0; + /** @type {!RegExp} */ + var re = new RegExp(this._sPattern, "g"); + var re_match; + var repeat_count_n; + var element_size_s; + /** @type {!Array} */ + var rv = []; + + //loop over chars in the format string with regex due to optional digits + for (; re_match = re.exec(fmt);) { + /** @type {number} */ + repeat_count_n = re_match[1] == undefined || re_match[1] == "" ? 1 : parseInt(re_match[1]); + element_size_s = this._lenLut[re_match[2]]; + if (offset_p + repeat_count_n * element_size_s > octet_array_a.length) { + return undefined; + } + switch(re_match[2]) { + case "A": + case "s": + rv.push(this._elLookUpTable[re_match[2]].de(octet_array_a, offset_p, repeat_count_n)); + break; + case "c": + case "b": + case "B": + case "h": + case "H": + case "i": + case "I": + case "l": + case "L": + case "f": + case "d": + case "q": + case "Q": + el = this._elLookUpTable[re_match[2]]; + + //rv.push(this._UnpackSeries(repeat_count_n, element_size_s, octet_array_a, offset_p)); + + // unpack arrays to an actual array type within the field array result: + // https://github.com/AndreasAntener/node-jspack/commit/4f16680101303a6b4a1b0deba8cf7d20fc68213e + if (repeat_count_n > 1) { + // Field is array, unpack into separate array and push as such + var arr = []; + arr.push(this._UnpackSeries(repeat_count_n, element_size_s, octet_array_a, offset_p)); + rv.push(arr); + } else { + rv.push(this._UnpackSeries(repeat_count_n, element_size_s, octet_array_a, offset_p)); + } + + break; + } + /** @type {number} */ + offset_p = offset_p + repeat_count_n * element_size_s; + } + return Array.prototype.concat.apply([], rv); + }; + + + // cross check the list of input data matches the size of bytes we'll be assembling + // this is a slightly tweaked implementation of the previous 'PackTo' commented out below. + // it has a more-consistent approach to input and output arrays, paying particular attention to Q,q, long, etc + m.WouldPack = function(fmt, octet_array_a, offset_p, values) { + //if (DEBUG) console.log("zzz_16 fmt:"+JSON.stringify(fmt)+" values:"+JSON.stringify(values)); + // @type {boolean} / + // Set the private bBE flag based on the format string - assume big-endianness + booleanIsBigEndian = fmt.charAt(0) != "<"; + // @type {!RegExp} / + var re = new RegExp(this._sPattern, "g"); + + var m; + var n; + var s; + var values_i = 0; // current index into the values[] + + var j; + for (; m = re.exec(fmt);) { + + // leading optional prefix num or 1 + n = m[1] == undefined || m[1] == "" ? 1 : parseInt(m[1]); + + s = this._lenLut[m[2]]; + + + if (DEBUG) console.log("character: "+m[2]+" how many(n)?: "+n); + el = this._elLookUpTable[m[2]]; + + //if (DEBUG) console.log("using lookup table:"+JSON.stringify(el)); + var bytes_consumed_per_element = el["len"]; + bytes_consumed_per_element = bytes_consumed_per_element == undefined ? 1 : bytes_consumed_per_element ; // undefined means 1 + if (DEBUG) console.log("bytes_consumed_per_element:"+JSON.stringify(bytes_consumed_per_element)); + if (DEBUG) console.log("current_values_idx:"+JSON.stringify(values_i) +" values:"+JSON.stringify(values[values_i]) ) ; + + + // do per-case behaviours 'A' , 's' and 'x' are special, everything else gets the same + switch(m[2]) { + //------------------------------------------ + case "A": + case "s": + if (values_i + 1 > values.length) { + console.log("JSPACK-ERROR: values_i + 1 > values.length values_i:"+values_i+" values.length:"+values.length); + //return false; + } + if (DEBUG) console.log("all values:"+JSON.stringify(values)); + this._elLookUpTable[m[2]].en(octet_array_a, offset_p, n, values[values_i]); + // @type {number} / + values_i = values_i + 1; + break; + //------------------------------------------ + case "x": + // @type {number} / + j = 0; + for (; j < n; j++) { + // @type {number} / + octet_array_a[offset_p + j] = 0; + } + break; + //------------------------------------------ + // everything else + default: + + // if n > 1 , ie it's multiple occurrences of a 'thing' + if (n > 1 ) { + + // if we were handed an array at this idx, we need the array to be the same length as n + if (Array.isArray(values[values_i])) { + + // Value series is array, iterate through that, only increment by 1 + if ((values_i + 1) > values.length) { + if (DEBUG) console.log("JSPACK-ERROR: value series is array but (values_i + 1) > values.length. i:"+values_i+" values.length:"+values.length); + //return false; + } + if (DEBUG) console.log("(dst IS array) (source IS array)"); + this._PackSeries(n, s, octet_array_a, offset_p, values[values_i], 0); + values_i += 1; + } + else { + if (DEBUG) console.log("ERROR: (dst IS array) (source is not array)"); + } + } + + // if n == 1, it just one of a thing + if (n == 1 ) { + + // type Q can have the source as an array when there is only 1 of them. + if (Array.isArray(values[values_i]) ) { + + if (( m[2] == 'Q' ) || ( m[2] == 'q' ) ) { + this._PackSeries(n, s, octet_array_a, offset_p, values[values_i], 0); + values_i += 1; + } + if (DEBUG) console.log("(dst is not array) (source IS array)"); + + } else { + if ((values_i + n ) > values.length) { + if (DEBUG) console.log("JSPACK-ERROR: value series NOT array but (values_i + n ) > values.length. i:"+values_i+" n:"+n+" values.length:"+values.length+" values:"+JSON.stringify(values)); + //return false; + } + if (DEBUG) console.log("(dst is not array) (source is not array)"); + this._PackSeries(n, s, octet_array_a, offset_p, values, values_i); + values_i += n; + } + } + + if (DEBUG) console.log(""); + break; + //------------------------------------------ + } + + offset_p = offset_p + n * s; + + } + if (DEBUG) console.log("wouldpack completed, result array_a is:"+JSON.stringify(octet_array_a)); + return octet_array_a + } + + + + /** + * @param {string} fmt + * @param {!Array} octet_array_a + * @param {number} offset_p + * @param {!NodeList} values + * @return {?} + */ +/* + // Pack the supplied values into the octet array a, beginning at offset p, according to the fmt string + m.PackTo = function(fmt, octet_array_a, offset_p, values) { + if (DEBUG) console.log("zzz_16 fmt:"+JSON.stringify(fmt)+" values:"+JSON.stringify(values)); + // @type {boolean} / + // Set the private bBE flag based on the format string - assume big-endianness + booleanIsBigEndian = fmt.charAt(0) != "<"; + // @type {!RegExp} / + var re = new RegExp(this._sPattern, "g"); + var m; + var n; + var s; + // @type {number} / + var i = 0; + var j; + for (; m = re.exec(fmt);) { + // @type {number} / + n = m[1] == undefined || m[1] == "" ? 1 : parseInt(m[1]); + s = this._lenLut[m[2]]; + if (offset_p + n * s > octet_array_a.length) { + console.log("JSPACK-ERROR: offset_p + n * s > octet_array_a.length offset_p:"+offset_p+" n:"+n+" s:"+s+" octet_array_a.length:"+octet_array_a.length+" octet_array_a:"+JSON.stringify(octet_array_a)); + return false; + } + if (DEBUG) console.log("\n---------------------------------------------\n"); + if (DEBUG) console.log("handling format specifier:"+m[2]+" how many:"+n); + switch(m[2]) { + case "A": + case "s": + if (i + 1 > values.length) { + console.log("JSPACK-ERROR: i + 1 > values.length i:"+i+" values.length:"+values.length); + return false; + } + if (DEBUG) console.log("zzz_16A values:"+JSON.stringify(values)); + this._elLookUpTable[m[2]].en(octet_array_a, offset_p, n, values[i]); + // @type {number} / + i = i + 1; + break; + case "c": + case "b": + case "B": + case "h": + case "H": + case "i": + case "I": + case "l": + case "L": + case "f": + case "d": + case "q": + case "Q": + if (DEBUG) console.log("16 blerg"); + el = this._elLookUpTable[m[2]]; + if (DEBUG) console.log("using lookup table:"+JSON.stringify(el)); + //if (i + n > values.length) { return false; } + //this._PackSeries(n, s, octet_array_a, offset_p, values, i); + //i = i + n; + //added support for packing value series when they are supplied as arrays within the values array + // https://github.com/AndreasAntener/node-jspack/commit/8de80d20aa06dea15527b3073c6c8631abda0f17 + if (n > 1 && Array.isArray(values[i])) { + // Value series is array, iterate through that, only increment by 1 + if ((i + 1) > values.length) { + console.log("JSPACK-ERROR: value series is array but (i + 1) > values.length. i:"+i+" values.length:"+values.length); + return false; + } + if (DEBUG) console.log("zzz_16 option 1 (source is array)"); + this._PackSeries(n, s, octet_array_a, offset_p, values[i], 0); + i += 1; + } else { + if ((i + n) > values.length) { + console.log("JSPACK-ERROR: value series NOT array but (i + n) > values.length. i:"+i+" n:"+n+" values.length:"+values.length+" values:"+JSON.stringify(values)); + //return false; + } + if (DEBUG) console.log("zzz_16 option 2 (source is not array)"); + this._PackSeries(n, s, octet_array_a, offset_p, values, i); + i += n; + } + + if (DEBUG) console.log("end case"); + break; + case "x": + // @type {number} / + j = 0; + for (; j < n; j++) { + // @type {number} / + octet_array_a[offset_p + j] = 0; + } + break; + } + // @type {number} / + offset_p = offset_p + n * s; + } + console.log("pack completed, result array_a is:"+JSON.stringify(octet_array_a)); + return octet_array_a; + }; + */ + + /** + * @param {string} fmt + * @param {(Node|NodeList|null|string)} values + * @return {?} + */ + // Pack the supplied values into a new octet array, according to the fmt string + m.Pack = function(fmt, values) { + if (DEBUG) console.log("\n\n------------------------------------------------------------------------------------------------------------\n\n"); + if (DEBUG) console.log("initial unpacked values:"+JSON.stringify(values)); + if (DEBUG) console.log("initial format string:"+JSON.stringify(fmt)); + if (DEBUG) console.log("\n\nwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww\n\n"); + return this.WouldPack(fmt, new Array(this.CalcLength(fmt)), 0, values); + //if (DEBUG) console.log("\n\nmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm\n\n"); + // return this.PackTo(fmt, new Array(this.CalcLength(fmt)), 0, values); + }; + + /** + * @param {string} fmt + * @param {(Node|NodeList|null|string)} values + * @return {?} + */ + // Pack the supplied values into a new octet array, according to the fmt string + m.oldPack = function(fmt, values) { + if (DEBUG) console.log("\n\n------------------------------------------------------------------------------------------------------------\n\n"); + if (DEBUG) console.log("initial unpacked values:"+JSON.stringify(values)); + if (DEBUG) console.log("initial format string:"+JSON.stringify(fmt)); + return this.PackTo(fmt, new Array(this.CalcLength(fmt)), 0, values); + }; + + /** + * @param {string} fmt + * @return {?} + */ + // Determine the number of bytes represented by the format string + m.CalcLength = function(fmt) { + + /** @type {!RegExp} */ + var re = new RegExp(this._sPattern, "g"); + var m; + /** @type {number} */ + var value = 0; + for (; m = re.exec(fmt);) { + /** @type {number} */ + value = value + (m[1] == undefined || m[1] == "" ? 1 : parseInt(m[1])) * this._lenLut[m[2]]; + } + if (DEBUG) console.log("number of bytes in format string?: "+value+"\n"); + return value; + }; +} + +export default JSPack + diff --git a/modules/MAVLink/local_modules/jspack/package.json b/modules/MAVLink/local_modules/jspack/package.json new file mode 100644 index 00000000..f9c36896 --- /dev/null +++ b/modules/MAVLink/local_modules/jspack/package.json @@ -0,0 +1,64 @@ +{ + "_from": "jspack@0.0.4", + "_id": "jspack@0.0.4", + "_inBundle": false, + "_integrity": "sha1-Mt01x/3LPjRWwY+7fvntC8YjgXc=", + "_location": "/jspack", + "_phantomChildren": {}, + "_requested": { + "type": "version", + "registry": false, + "raw": "jspack@0.0.4", + "name": "jspack", + "escapedName": "jspack", + "rawSpec": "0.0.4", + "saveSpec": null, + "fetchSpec": "0.0.4" + }, + "_requiredBy": [ + "#USER", + "/" + ], + "_resolved": "local_modules/jspack", + "_shasum": "32dd35c7fdcb3e3456c18fbb7ef9ed0bc6238177", + "_spec": "jspack@0.0.4", + "_where": "/home/buzz/GCS/mavlink/pymavlink/generator/javascript", + "author": { + "name": "https://github.com/pgriess" + }, + "bugs": { + "url": "https://github.com/birchroad/node-jspack/issues" + }, + "bundleDependencies": false, + "deprecated": false, + "description": "JavaScript library to pack primitives to octet arrays, including int64 support, packaged for NodeJS.", + "devDependencies": { + "long": "", + "mocha": "", + "should": "", + "sinon": "" + }, + "homepage": "https://github.com/birchroad/node-jspack", + "main": "./jspack.js", + "maintainers": [ + { + "name": "Peter Magnusson", + "email": "peter@birchroad.net", + "url": "http://github.com/birchroad/node-jspack" + }, + { + "name": "Andreas Antener", + "url": "https://github.com/AndreasAntener/node-jspack" + } + ], + "name": "jspack", + "repository": { + "type": "git", + "url": "https://github.com/birchroad/node-jspack.git" + }, + "scripts": { + "pretest": "npm install", + "test": "mocha test" + }, + "version": "0.0.4" +} diff --git a/modules/MAVLink/local_modules/jspack/test/int64.js b/modules/MAVLink/local_modules/jspack/test/int64.js new file mode 100644 index 00000000..fc72eb49 --- /dev/null +++ b/modules/MAVLink/local_modules/jspack/test/int64.js @@ -0,0 +1,456 @@ +// This file is MODIFIED from the original, by buzz 2020, please see README.md in the upper level folder for more details. +var should = require('should'); +var jspack = require('../jspack.js').jspack; +var Long = require('long'); + +describe('Test long integration (examples):', function() { + + // Demonstrating the use together with Long.js (https://github.com/dcodeIO/Long.js) + // + // Packing a long requires the input of a 2 part array containing the [low, high] bits + // of the specific long value. + // Unpacking a long results in a 3 part array containing [low, high, unsigned] bits and flag. + // The decoded value can be applied directly to Long.fromBits() + // + // Test number u 228290380562207 (BE: 0x00, 0x00, 0xcf, 0xa0, 0xff, 0x09, 0xff, 0x1f) + // (LE: 0x1f, 0xff, 0x09, 0xff, 0xa0, 0xcf, 0x00, 0x00) + // Test number s -228290380562207 (BE: 0xff, 0xff, 0x30, 0x5f, 0x00, 0xf6, 0x00, 0xe1) + // (LE: 0xe1, 0x00, 0xf6, 0x00, 0x5f, 0x30, 0xff, 0xff) + + it('pack Q', function() { + var buf = jspack.Pack('>Q', [[0xffe1ffff, 0xffa0]]); + buf.should.be.eql([0x00, 0x00, 0xff, 0xa0, 0xff, 0xe1, 0xff, 0xff]); + }); + + it('unpack Q', function() { + var buf = jspack.Unpack('>Q', [0x00, 0x00, 0xff, 0xa0, 0xff, 0xe1, 0xff, 0xff]); + buf.length.should.be.eql(1); + buf[0].length.should.be.eql(3); + buf[0][0].should.be.eql(0xffe1ffff); + buf[0][1].should.be.eql(0xffa0); + buf[0][2].should.be.true; + }); + + // Test lower-case q as well. This only test the matching of the character and the unsigned bit, + // the parsing is the same as for upper-case Q (since we don't actually convert to a number). + it('pack >q (signed)', function() { + var buf = jspack.Pack('>q', [[0xffe1ffff, 0xffa0]]); + buf.should.be.eql([0x00, 0x00, 0xff, 0xa0, 0xff, 0xe1, 0xff, 0xff]); + }); + + it('unpack >> 0).toString(2); + y = ("00000000000000000000000000000000" + x).slice(-32) + y1 = y.substring(0,8); + y2 = y.substring(8,16); + y3 = y.substring(16,24); + y4 = y.substring(24,32); + return [y,y1,y2,y3,y4]; +} +function dec2bin_ws(dec) { + var str = dec2bin(dec); + var bb = str.slice(1); //1-4 skipping zero + var bbj = bb.join(' '); + return bbj; +} + +describe('ASCII Boundary tests:', function() { + + it('pack <4s correctly over the ascii 127->128->129 boundary', function() { // should work in range 0-255 if u use 'binary' encoding + + this.format = '<4s'; + + this.ascii_bytes = new Buffer.from([ 126, 127, 128, 129]).toString('binary'); // 'binary' encoding is important here, as without it values above 128 are treated as unicode. + var buf = jspack.Pack(this.format, [ this.ascii_bytes]); + body = [ 0x7e, 0x7f, 0x80, 0x81]; // expected result + buf.should.be.eql(body); + + }); + + it('long Q buzz', function() { // should work in range 0-255 if u use 'binary' encoding + +//from aoa_ssa + + this.format = '> 8) & 0xFF), this.msgId>>16]; + + this.msgId = 130; + + var v1 = ((this.msgId & 0xFF) << 8) | ((this.msgId >> 8) & 0xFF); + var v2 = this.msgId>>16; + + v1.should.be.eql(33280); + v2.should.be.eql(0); + + var orderedfields = [253,13,0,0,40,11,10,33280,0]; + + console.log("------------------------------------------------------------------------\nmavheader:"+JSON.stringify(orderedfields)); + var hdr = jspack.Pack('BBBBBBBHB',orderedfields); + + buf = [0xfd, 0x0d, 0x00, 0x00, 0x28, 0x0b, 0x0a, 0x82, 0x00, 0x00]; + + buf.should.be.eql(hdr); + }); + +}); + +describe('Q Boundary tests:', function() { + + it('unpack >Q full', function() { + var buf = jspack.Unpack('>Q', [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + buf.length.should.be.eql(1); + buf[0].length.should.be.eql(3); + buf[0][0].should.be.eql(0xffffffff); + buf[0][1].should.be.eql(0xffffffff); + buf[0][2].should.be.true; + }); + + it('pack >Q full', function() { + var buf = jspack.Pack('>Q', [[0xffffffff, 0xffffffff]]); + buf.should.be.eql([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + }); + + it('unpack Q zero', function() { + var buf = jspack.Unpack('>Q', [0, 0, 0, 0, 0, 0, 0, 0]); + buf.length.should.be.eql(1); + buf[0].length.should.be.eql(3); + buf[0][0].should.be.eql(0); + buf[0][1].should.be.eql(0); + buf[0][2].should.be.true; + }); + + it('pack >Q zero', function() { + var buf = jspack.Pack('>Q', [[0, 0]]); + buf.should.be.eql([0, 0, 0, 0, 0, 0, 0, 0]); + }); + + it('unpack Q one', function() { + var buf = jspack.Unpack('>Q', [1, 1, 1, 1, 1, 1, 1, 1]); + buf.length.should.be.eql(1); + buf[0].length.should.be.eql(3); + buf[0][0].should.be.eql(0x01010101); + buf[0][1].should.be.eql(0x01010101); + buf[0][2].should.be.true; + }); + + it('pack >Q one', function() { + var buf = jspack.Pack('>Q', [[0x01010101, 0x01010101]]); + buf.should.be.eql([1, 1, 1, 1, 1, 1, 1, 1]); + }); + + it('unpack Q 0xfe', function() { + var buf = jspack.Unpack('>Q', [0xfe, 0xfe, 0xfe, 0xfe, 0xfe, 0xfe, 0xfe, 0xfe]); + buf.length.should.be.eql(1); + buf[0].length.should.be.eql(3); + buf[0][0].should.be.eql(0xfefefefe); + buf[0][1].should.be.eql(0xfefefefe); + buf[0][2].should.be.true; + }); + + it('pack >Q 0xfe', function() { + var buf = jspack.Pack('>Q', [[0xfefefefe, 0xfefefefe]]); + buf.should.be.eql([0xfe, 0xfe, 0xfe, 0xfe, 0xfe, 0xfe, 0xfe, 0xfe]); + }); + + it('unpack c.charCodeAt(0))); + } + + // Ensure we have at least the header + if (payload.length < this.HDR_LEN) { + console.error(`FTP: Payload too short (${payload.length} bytes)`); + return null; + } + + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + + const op = { + seq: view.getUint16(0, true), + session: view.getUint8(2), + opcode: view.getUint8(3), + size: view.getUint8(4), + req_opcode: view.getUint8(5), + burst_complete: view.getUint8(6), + offset: view.getUint32(8, true), + payload: null + }; + + // Extract payload if present and size > 0 + if (op.size > 0 && payload.length > this.HDR_LEN) { + const payloadStart = this.HDR_LEN; + const payloadLength = Math.min(op.size, payload.length - this.HDR_LEN); + op.payload = new Uint8Array(payload.buffer, payload.byteOffset + payloadStart, payloadLength); + } + + return op; + } + + // Send FTP operation + sendOp(opcode, size, req_opcode, burst_complete, offset, payload) { + const packedOp = this.packOp(this.seq, this.session, opcode, size, req_opcode, burst_complete, offset, payload); + + // Send via FILE_TRANSFER_PROTOCOL message + const msg = new mavlink20.messages.file_transfer_protocol( + 0, // network (0 for Mavlink 2.0) + this.targetSystem, + this.targetComponent, + Array.from(packedOp) + ); + + const pkt = msg.pack(this.MAVLink); + this.ws.send(Uint8Array.from(pkt)); + + this.lastOp = { opcode, size, req_opcode, burst_complete, offset, payload }; + + console.log(`FTP: Sent op ${opcode} (${this.getOpName(opcode)}) seq=${this.seq} session=${this.session} offset=${offset}`); + this.seq = (this.seq + 1) % 256; + } + + // Helper to get operation name for debugging + getOpName(opcode) { + for (let [name, code] of Object.entries(this.OP)) { + if (code === opcode) return name; + } + return `Unknown(${opcode})`; + } + + // Terminate current session + terminateSession() { + this.sendOp(this.OP.TerminateSession, 0, 0, 0, 0, null); + this.session = (this.session + 1) % 256; + this.currentFile = null; + this.fileBuffer = null; + this.readGaps = []; + this.reachedEOF = false; + console.log("FTP: Session terminated"); + } + + // Get file from vehicle + getFile(filename, callback) { + console.log(`FTP: Getting file ${filename}`); + + this.terminateSession(); + this.callback = callback; + this.currentFile = filename; + this.fileBuffer = new Uint8Array(0); + this.readGaps = []; + this.reachedEOF = false; + this.opStartTime = Date.now(); + + // Encode filename + const encoder = new TextEncoder(); + const filenameBytes = encoder.encode(filename); + + // Send OpenFileRO + this.sendOp(this.OP.OpenFileRO, filenameBytes.length, 0, 0, 0, filenameBytes); + } + + // Handle incoming FILE_TRANSFER_PROTOCOL message + handleMessage(m) { + if (m._name !== "FILE_TRANSFER_PROTOCOL") return; + if (m.target_system !== this.MAVLink.srcSystem || m.target_component !== this.MAVLink.srcComponent) return; + + // m.payload is already a Uint8Array, pass it directly + const op = this.parseOp(m.payload); + if (!op) { + console.error("FTP: Failed to parse operation"); + return; + } + + console.log(`FTP: Received ${this.getOpName(op.opcode)} for req=${this.getOpName(op.req_opcode)} size=${op.size} offset=${op.offset} seq=${op.seq}`); + + // Handle different response types + if (op.req_opcode === this.OP.OpenFileRO) { + this.handleOpenResponse(op); + } else if (op.req_opcode === this.OP.BurstReadFile) { + this.handleBurstReadResponse(op); + } else if (op.req_opcode === this.OP.ReadFile) { + this.handleReadResponse(op); + } else if (op.req_opcode === this.OP.TerminateSession) { + console.log("FTP: Session terminated ACK"); + } else { + console.log(`FTP: Unhandled response for ${this.getOpName(op.req_opcode)}`); + } + } + + // Handle OpenFileRO response + handleOpenResponse(op) { + if (op.opcode === this.OP.Ack) { + console.log("FTP: File opened, starting burst read"); + // Start burst read from offset 0 + this.sendOp(this.OP.BurstReadFile, this.burstSize, 0, 0, 0, null); + } else if (op.opcode === this.OP.Nack) { + console.error("FTP: Failed to open file - NACK received"); + if (this.callback) this.callback(null); + this.terminateSession(); + } else { + console.error(`FTP: Unexpected response to OpenFileRO: opcode ${op.opcode}`); + } + } + + // Handle BurstReadFile response + handleBurstReadResponse(op) { + if (op.opcode === this.OP.Ack && op.payload) { + // Expand buffer if needed + const newSize = op.offset + op.size; + if (newSize > this.fileBuffer.length) { + const newBuffer = new Uint8Array(newSize); + newBuffer.set(this.fileBuffer); + this.fileBuffer = newBuffer; + } + + // Write data at offset + this.fileBuffer.set(op.payload, op.offset); + + console.log(`FTP: Read ${op.size} bytes at offset ${op.offset}`); + + // Check if we need to continue + if (op.burst_complete) { + if (op.size > 0 && op.size < this.burstSize) { + // EOF reached + this.reachedEOF = true; + this.finishTransfer(); + } else { + // Continue reading + const nextOffset = op.offset + op.size; + this.sendOp(this.OP.BurstReadFile, this.burstSize, 0, 0, nextOffset, null); + } + } + } else if (op.opcode === this.OP.Nack) { + const errorCode = op.payload ? op.payload[0] : 0; + if (errorCode === this.ERR.EndOfFile || errorCode === 0) { + console.log("FTP: EOF reached"); + this.reachedEOF = true; + this.finishTransfer(); + } else { + console.error(`FTP: Read failed with error ${errorCode}`); + if (this.callback) this.callback(null); + this.terminateSession(); + } + } + } + + // Handle ReadFile response (for gap filling) + handleReadResponse(op) { + if (op.opcode === this.OP.Ack && op.payload) { + // Fill gap + this.fileBuffer.set(op.payload, op.offset); + + // Remove from gaps list + this.readGaps = this.readGaps.filter(g => g.offset !== op.offset); + + console.log(`FTP: Filled gap at ${op.offset}, ${this.readGaps.length} gaps remaining`); + + if (this.readGaps.length === 0 && this.reachedEOF) { + this.finishTransfer(); + } + } + } + + // Finish file transfer + finishTransfer() { + if (!this.fileBuffer) { + return; + } + const dt = (Date.now() - this.opStartTime) / 1000; + const size = this.fileBuffer.length; + const rate = (size / dt) / 1024; + + console.log(`FTP: Transfer complete - ${size} bytes in ${dt.toFixed(2)}s (${rate.toFixed(1)} KB/s)`); + + if (this.callback) { + this.callback(this.fileBuffer); + } + + this.terminateSession(); + } +} + +// Fence data parser +class FenceParser { + constructor() { + } + + parseMissionItems(data) { + var header = data.buffer.slice(0,10); + var hdr = jspack.Unpack(" { + jspack = new mod.default() + }).catch((e) => { + }); +} + +// Handle Node.js specific modules +let events, util; +if (isNode) { + events = require("events"); + util = require("util"); +} else { + // Browser polyfills for Node.js modules + util = { + inherits: function(constructor, superConstructor) { + constructor.prototype = Object.create(superConstructor.prototype); + constructor.prototype.constructor = constructor; + } + }; + + // Simple EventEmitter polyfill for browsers + events = { + EventEmitter: function() { + this._events = {}; + + this.on = function(event, listener) { + if (!this._events[event]) { + this._events[event] = []; + } + this._events[event].push(listener); + }; + + this.emit = function(event, ...args) { + if (this._events[event]) { + this._events[event].forEach(listener => { + try { + listener.apply(this, args); + } catch (e) { + console.error('Error in event listener:', e); + } + }); + } + }; + + this.removeListener = function(event, listener) { + if (this._events[event]) { + const index = this._events[event].indexOf(listener); + if (index > -1) { + this._events[event].splice(index, 1); + } + } + }; + } + }; +} + + +mavlink20 = function(){}; + +// Implement the CRC-16/MCRF4XX function (present in the Python version through the mavutil.py package) +mavlink20.x25Crc = function(buffer, crcIN) { + + var bytes = buffer; + var crcOUT = crcIN === undefined ? 0xffff : crcIN; + bytes.forEach(function(e) { + var tmp = e ^ (crcOUT & 0xff); + tmp = (tmp ^ (tmp << 4)) & 0xff; + crcOUT = (crcOUT >> 8) ^ (tmp << 8) ^ (tmp << 3) ^ (tmp >> 4); + crcOUT = crcOUT & 0xffff; + }); + return crcOUT; + +} + +mavlink20.WIRE_PROTOCOL_VERSION = "2.0"; +mavlink20.PROTOCOL_MARKER_V1 = 0xFE +mavlink20.PROTOCOL_MARKER_V2 = 0xFD +mavlink20.HEADER_LEN_V1 = 6 +mavlink20.HEADER_LEN_V2 = 10 +mavlink20.HEADER_LEN = 10; + +mavlink20.MAVLINK_TYPE_CHAR = 0 +mavlink20.MAVLINK_TYPE_UINT8_T = 1 +mavlink20.MAVLINK_TYPE_INT8_T = 2 +mavlink20.MAVLINK_TYPE_UINT16_T = 3 +mavlink20.MAVLINK_TYPE_INT16_T = 4 +mavlink20.MAVLINK_TYPE_UINT32_T = 5 +mavlink20.MAVLINK_TYPE_INT32_T = 6 +mavlink20.MAVLINK_TYPE_UINT64_T = 7 +mavlink20.MAVLINK_TYPE_INT64_T = 8 +mavlink20.MAVLINK_TYPE_FLOAT = 9 +mavlink20.MAVLINK_TYPE_DOUBLE = 10 + +mavlink20.MAVLINK_IFLAG_SIGNED = 0x01 +mavlink20.MAVLINK_SIGNATURE_BLOCK_LEN = 13 + +// Mavlink headers incorporate sequence, source system (platform) and source component. +mavlink20.header = function(msgId, mlen, seq, srcSystem, srcComponent, incompat_flags=0, compat_flags=0) { + + this.mlen = ( typeof mlen === 'undefined' ) ? 0 : mlen; + this.seq = ( typeof seq === 'undefined' ) ? 0 : seq; + this.srcSystem = ( typeof srcSystem === 'undefined' ) ? 0 : srcSystem; + this.srcComponent = ( typeof srcComponent === 'undefined' ) ? 0 : srcComponent; + this.msgId = msgId + this.incompat_flags = incompat_flags + this.compat_flags = compat_flags + +} +mavlink20.header.prototype.pack = function() { + return jspack.Pack('BBBBBBBHB', [253, this.mlen, this.incompat_flags, this.compat_flags, this.seq, this.srcSystem, this.srcComponent, ((this.msgId & 0xFF) << 8) | ((this.msgId >> 8) & 0xFF), this.msgId>>16]); +} + +// Base class declaration: mavlink.message will be the parent class for each +// concrete implementation in mavlink.messages. +mavlink20.message = function() {}; + +// Convenience setter to facilitate turning the unpacked array of data into member properties +mavlink20.message.prototype.set = function(args,verbose) { + // inspect + this.fieldnames.forEach(function(e, i) { + var num = parseInt(i,10); + if (this.hasOwnProperty(e) && isNaN(num) ){ // asking for an attribute that's non-numeric is ok unless its already an attribute we have + if ( verbose >= 1) { console.log("WARNING, overwriting an existing property is DANGEROUS:"+e+" ==>"+i+"==>"+args[i]+" -> "+JSON.stringify(this)); } + } + }, this); + + // then modify + this.fieldnames.forEach(function(e, i) { + this[e] = args[i]; + }, this); +}; + +/* + sha256 implementation + embedded to avoid async issues in web browsers with crypto library + with thanks to https://geraintluff.github.io/sha256/ +*/ +mavlink20.sha256 = function(inputBytes) { + const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, + 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, + 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, + 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, + 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, + 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, + 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, + 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]); + + function ROTR(n, x) { return (x >>> n) | (x << (32 - n)); } + + function Σ0(x) { return ROTR(2, x) ^ ROTR(13, x) ^ ROTR(22, x); } + function Σ1(x) { return ROTR(6, x) ^ ROTR(11, x) ^ ROTR(25, x); } + function σ0(x) { return ROTR(7, x) ^ ROTR(18, x) ^ (x >>> 3); } + function σ1(x) { return ROTR(17, x) ^ ROTR(19, x) ^ (x >>> 10); } + + function Ch(x, y, z) { return (x & y) ^ (~x & z); } + function Maj(x, y, z) { return (x & y) ^ (x & z) ^ (y & z); } + + const H = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 + ]); + + const l = inputBytes.length; + const bitLen = l * 8; + + const withPadding = new Uint8Array(((l + 9 + 63) >> 6) << 6); // pad to multiple of 64 bytes + withPadding.set(inputBytes); + withPadding[l] = 0x80; + withPadding.set([ + 0, 0, 0, 0, + (bitLen >>> 24) & 0xff, + (bitLen >>> 16) & 0xff, + (bitLen >>> 8) & 0xff, + bitLen & 0xff + ], withPadding.length - 8); + + const w = new Uint32Array(64); + for (let i = 0; i < withPadding.length; i += 64) { + for (let j = 0; j < 16; j++) { + w[j] = ( + (withPadding[i + 4 * j] << 24) | + (withPadding[i + 4 * j + 1] << 16) | + (withPadding[i + 4 * j + 2] << 8) | + (withPadding[i + 4 * j + 3]) + ) >>> 0; + } + for (let j = 16; j < 64; j++) { + w[j] = (σ1(w[j - 2]) + w[j - 7] + σ0(w[j - 15]) + w[j - 16]) >>> 0; + } + + let [a, b, c, d, e, f, g, h] = H; + + for (let j = 0; j < 64; j++) { + const T1 = (h + Σ1(e) + Ch(e, f, g) + K[j] + w[j]) >>> 0; + const T2 = (Σ0(a) + Maj(a, b, c)) >>> 0; + h = g; + g = f; + f = e; + e = (d + T1) >>> 0; + d = c; + c = b; + b = a; + a = (T1 + T2) >>> 0; + } + + H[0] = (H[0] + a) >>> 0; + H[1] = (H[1] + b) >>> 0; + H[2] = (H[2] + c) >>> 0; + H[3] = (H[3] + d) >>> 0; + H[4] = (H[4] + e) >>> 0; + H[5] = (H[5] + f) >>> 0; + H[6] = (H[6] + g) >>> 0; + H[7] = (H[7] + h) >>> 0; + } + + const output = new Uint8Array(32); + for (let i = 0; i < 8; i++) { + output[i * 4 + 0] = (H[i] >>> 24) & 0xff; + output[i * 4 + 1] = (H[i] >>> 16) & 0xff; + output[i * 4 + 2] = (H[i] >>> 8) & 0xff; + output[i * 4 + 3] = H[i] & 0xff; + } + + return output; +} + +// create a message signature +mavlink20.create_signature = function(key, msgbuf) { + const input = new Uint8Array(32 + msgbuf.length); + input.set(key, 0); + input.set(msgbuf, 32); + + const hash = mavlink20.sha256(input); + const sig = hash.slice(0, 6); + + return sig; +} + +// sign outgoing packet +mavlink20.message.prototype.sign_packet = function( mav) { + function packUint48LE(value) { + const bytes = [] + for (let i = 0; i < 6; i++) { + bytes.push(Number((value >> BigInt(8 * i)) & 0xFFn)); + } + return bytes; + } + + var tsbuf = packUint48LE(BigInt(mav.signing.timestamp)); + + // first add the linkid(1 byte) and timestamp(6 bytes) that start the signature + this._msgbuf = this._msgbuf.concat([mav.signing.link_id]) + this._msgbuf = this._msgbuf.concat(tsbuf); + + sig = mavlink20.create_signature(mav.signing.secret_key, this._msgbuf); + this._msgbuf = this._msgbuf.concat( ... sig ); + + mav.signing.timestamp += 1; +} + +// This pack function builds the header and produces a complete MAVLink message, +// including header and message CRC. +mavlink20.message.prototype.pack = function(mav, crc_extra, payload) { + + this._payload = payload; + var plen = this._payload.length; + //in MAVLink2 we can strip trailing zeros off payloads. This allows for simple + // variable length arrays and smaller packets + while ((plen > 1) && ( (this._payload[plen-1] == 0) || (this._payload[plen-1] == null) ) ) { + plen = plen - 1; + } + this._payload = this._payload.slice(0, plen); + // signing is our first incompat flag. + var incompat_flags = 0; + if (mav.signing.sign_outgoing){ + incompat_flags |= mavlink20.MAVLINK_IFLAG_SIGNED + } + // header + this._header = new mavlink20.header(this._id, this._payload.length, mav.seq, mav.srcSystem, mav.srcComponent, incompat_flags, 0,); + // payload + this._msgbuf = this._header.pack().concat(this._payload); + // crc - for now, assume always using crc_extra = True. TODO: check/fix this. + var crc = mavlink20.x25Crc(this._msgbuf.slice(1)); + crc = mavlink20.x25Crc([crc_extra], crc); + this._msgbuf = this._msgbuf.concat(jspack.Pack(' MAV. Also used to +return a point from MAV -> GCS. + + target_system : System ID. (uint8_t) + target_component : Component ID. (uint8_t) + idx : Point index (first point is 1, 0 is for return point). (uint8_t) + count : Total number of points (for sanity checking). (uint8_t) + lat : Latitude of point. (float) + lng : Longitude of point. (float) + +*/ + mavlink20.messages.fence_point = function( ...moreargs ) { + [ this.target_system , this.target_component , this.idx , this.count , this.lat , this.lng ] = moreargs; + + this._format = ' MAV. Also used to +return a point from MAV -> GCS. + + target_system : System ID. (uint8_t) + target_component : Component ID. (uint8_t) + idx : Point index (first point is 0). (uint8_t) + count : Total number of points (for sanity checking). (uint8_t) + lat : Latitude of point. (int32_t) + lng : Longitude of point. (int32_t) + alt : Transit / loiter altitude relative to home. (int16_t) + break_alt : Break altitude relative to home. (int16_t) + land_dir : Heading to aim for when landing. (uint16_t) + flags : Configuration flags. (uint8_t) + +*/ + mavlink20.messages.rally_point = function( ...moreargs ) { + [ this.target_system , this.target_component , this.idx , this.count , this.lat , this.lng , this.alt , this.break_alt , this.land_dir , this.flags ] = moreargs; + + this._format = ' value[float]. +This allows to send a parameter to any other component (such as the +GCS) without the need of previous knowledge of possible parameter +names. Thus the same GCS can store different parameters for different +autopilots. See also https://mavlink.io/en/services/parameter.html for +a full documentation of QGroundControl and IMU code. + + target_system : System ID (uint8_t) + target_component : Component ID (uint8_t) + param_id : Onboard parameter id, terminated by NULL if the length is less than 16 human-readable chars and WITHOUT null termination (NULL) byte if the length is exactly 16 chars - applications have to provide 16+1 bytes storage if the ID is stored as string (char) + param_index : Parameter index. Send -1 to use the param ID field as identifier (else the param id will be ignored) (int16_t) + +*/ + mavlink20.messages.param_request_read = function( ...moreargs ) { + [ this.target_system , this.target_component , this.param_id , this.param_index ] = moreargs; + + this._format = ' 0 indicates the interval at which it is sent. (int32_t) + +*/ + mavlink20.messages.message_interval = function( ...moreargs ) { + [ this.message_id , this.interval_us ] = moreargs; + + this._format = '= 1 && + this.buf[0] != mavlink20.PROTOCOL_MARKER_V2 && + this.buf[0] != mavlink20.PROTOCOL_MARKER_V1) { + + // Strip the offending initial bytes and throw an error. + var badPrefix = this.buf[0]; + var idx1 = this.buf.indexOf(mavlink20.PROTOCOL_MARKER_V1); + var idx2 = this.buf.indexOf(mavlink20.PROTOCOL_MARKER_V2); + if (idx1 == -1) { + idx1 = idx2; + } + if (idx1 == -1 && idx2 == -1) { + this.bufInError = this.buf; + this.buf = new Uint8Array(); + } else { + this.bufInError = this.buf.slice(0,idx1); + this.buf = this.buf.slice(idx1); + } + this.expected_length = mavlink20.HEADER_LEN; //initially we 'expect' at least the length of the header, later parseLength corrects for this. + throw new Error("Bad prefix ("+badPrefix+")"); + } + +} + +// Determine the length. Leaves buffer untouched. +// Although the 'len' of a packet is available as of the second byte, the third byte with 'incompat_flags' lets +// us know if we have signing enabled, which affects the real-world length by the signature-block length of 13 bytes. +// once successful, 'this.expected_length' is correctly set for the whole packet. +MAVLink20Processor.prototype.parseLength = function() { + + if( this.buf.length >= 3 ) { + var unpacked = jspack.Unpack('BBB', this.buf.slice(0, 3)); + var magic = unpacked[0]; // stx ie fd or fe etc + this.expected_length = unpacked[1] + mavlink20.HEADER_LEN + 2 // length of message + header + CRC (ie non-signed length) + this.incompat_flags = unpacked[2]; + // mavlink2 only.. in mavlink1, incompat_flags var above is actually the 'seq', but for this test its ok. + if ((magic == mavlink20.PROTOCOL_MARKER_V2 ) && ( this.incompat_flags & mavlink20.MAVLINK_IFLAG_SIGNED )){ + this.expected_length += mavlink20.MAVLINK_SIGNATURE_BLOCK_LEN; + } + } + +} + +// input some data bytes, possibly returning a new message - python equiv function is called parse_char / __parse_char_legacy +// c can be null to process any remaining data in the input buffer from a previous call +MAVLink20Processor.prototype.parseChar = function(c) { + + var m = null; + + try { + if (c != null) { + this.pushBuffer(c); + } + this.parsePrefix(); + this.parseLength(); + m = this.parsePayload(); + + } catch(e) { + this.log('error', e.message); + this.total_receive_errors += 1; + m = new mavlink20.messages.bad_data(this.bufInError, e.message); + this.bufInError = new Uint8Array(); + + } + + // emit a packet-specific message as well as a generic message, user/s can choose to use either or both of these. + if (isNode && null != m) { + this.emit(m._name, m); + this.emit('message', m); + } + + return m; + +} + +// continuation of python's __parse_char_legacy +MAVLink20Processor.prototype.parsePayload = function() { + + var m = null; + + // tip: this.expected_length and this.incompat_flags both already set correctly by parseLength(..) above + + // If we have enough bytes to try and read it, read it. + // shortest packet is header+checksum(2) with no payload, so we need at least that many + // but once we have a longer 'expected length' we have to read all of it. + if(( this.expected_length >= mavlink20.HEADER_LEN+2) && (this.buf.length >= this.expected_length) ) { + + // Slice off the expected packet length, reset expectation to be to find a header. + var mbuf = this.buf.slice(0, this.expected_length); + + // TODO: slicing off the buffer should depend on the error produced by the decode() function + // - if we find a well formed message, cut-off the expected_length + // - if the message is not well formed (correct prefix by accident), cut-off 1 char only + this.buf = this.buf.slice(this.expected_length); + this.expected_length = mavlink20.HEADER_LEN; // after attempting a parse, we'll next expect to find just a header. + + try { + m = this.decode(mbuf); + this.total_packets_received += 1; + } + catch(e) { + // Set buffer in question and re-throw to generic error handling + this.bufInError = mbuf; + throw e; + } + } + + return m; + +} + +// input some data bytes, possibly returning an array of new messages +MAVLink20Processor.prototype.parseBuffer = function(s) { + + // Get a message, if one is available in the stream. + var m = this.parseChar(s); + + // No messages available, bail. + if ( null === m ) { + return null; + } + + // While more valid messages can be read from the existing buffer, add + // them to the array of new messages and return them. + var ret = []; + ret.push(m); + while(true) { + m = this.parseChar(null); + if ( null === m ) { + // No more messages left. + return ret; + } + ret.push(m); + } + +} + +//check signature on incoming message , many of the comments in this file come from the python impl +MAVLink20Processor.prototype.check_signature = function(msgbuf, srcSystem, srcComponent) { + var timestamp_buf = msgbuf.slice(-12,-6); + + var link_id; + if (isNode) { + var link_id_buf = Buffer.from ? Buffer.from(msgbuf.slice(-13,-12)) : new Buffer(msgbuf.slice(-13,-12)); + link_id = link_id_buf[0]; // get the first byte. + } else { + // Browser-compatible buffer handling + link_id = msgbuf.slice(-13,-12)[0]; + } + + function unpackUint48LE(bytes) { + let value = 0n; + for (let i = 5; i >= 0; i--) { + value = (value << 8n) | BigInt(bytes[i]); + } + return value; + } + var timestamp = Number(unpackUint48LE(timestamp_buf)); + + // see if the timestamp is acceptable + + // we'll use a STRING containing these three things in it as a unique key eg: '0,1,1' + stream_key = new Array(link_id,srcSystem,srcComponent).toString(); + + if (stream_key in this.signing.stream_timestamps){ + if (timestamp <= this.signing.stream_timestamps[stream_key]){ + //# reject old timestamp + //console.log('old timestamp') + return false + } + }else{ + //# a new stream has appeared. Accept the timestamp if it is at most + //# one minute behind our current timestamp + if (timestamp + 6000*1000 < this.signing.timestamp){ + //console.log('bad new stream ', timestamp/(100.0*1000*60*60*24*365), this.signing.timestamp/(100.0*1000*60*60*24*365)) + return false + } + this.signing.stream_timestamps[stream_key] = timestamp; + //console.log('new stream',this.signing.stream_timestamps) + } + + // just the last 6 of 13 available are the actual sig . ie excluding the linkid(1) and timestamp(6) + var sigpart = msgbuf.slice(-6); + sigpart = Uint8Array.from(sigpart); + // not sig part 0- end-minus-6 + var notsigpart = msgbuf.slice(0,-6); + notsigpart = Uint8Array.from(notsigpart); + + var sig1 = mavlink20.create_signature(this.signing.secret_key, notsigpart); + + // Browser-compatible buffer comparison + var signaturesMatch; + if (isNode) { + signaturesMatch = Buffer.from(sig1).equals(Buffer.from(sigpart)); + } else { + // Compare arrays element by element in browser + signaturesMatch = sig1.length === sigpart.length && + sig1.every((val, index) => val === sigpart[index]); + } + if (!signaturesMatch) { + return false; + } + //# the timestamp we next send with is the max of the received timestamp and + //# our current timestamp + this.signing.timestamp = Math.max(this.signing.timestamp, timestamp+1); + return true +} + +/* decode a buffer as a MAVLink message */ +MAVLink20Processor.prototype.decode = function(msgbuf) { + + var magic, incompat_flags, compat_flags, mlen, seq, srcSystem, srcComponent, unpacked, msgId, signature_len, header_len; + + // decode the header + try { + if (msgbuf[0] == 253) { + var unpacked = jspack.Unpack('BBBBBBBHB', msgbuf.slice(0, 10)); // the H in here causes msgIDlow to takeup 2 bytes, the rest 1 + magic = unpacked[0]; + mlen = unpacked[1]; + incompat_flags = unpacked[2]; + compat_flags = unpacked[3]; + seq = unpacked[4]; + srcSystem = unpacked[5]; + srcComponent = unpacked[6]; + var msgIDlow = ((unpacked[7] & 0xFF) << 8) | ((unpacked[7] >> 8) & 0xFF); // first-two msgid bytes + var msgIDhigh = unpacked[8]; // the 3rd msgid byte + msgId = msgIDlow | (msgIDhigh<<16); // combined result. 0 - 16777215 24bit number + header_len = 10; +} else { + var unpacked = jspack.Unpack('BBBBBB', msgbuf.slice(0, 6)); + magic = unpacked[0]; + mlen = unpacked[1]; + seq = unpacked[2]; + srcSystem = unpacked[3]; + srcComponent = unpacked[4]; + msgID = unpacked[5]; + incompat_flags = 0; + compat_flags = 0; + header_len = 6; +} + } + catch(e) { + throw new Error('Unable to unpack MAVLink header: ' + e.message); + } + + if (magic != this.protocol_marker) { + throw new Error("Invalid MAVLink prefix ("+magic+")"); + } + + // is packet supposed to be signed? + if ( incompat_flags & mavlink20.MAVLINK_IFLAG_SIGNED ){ + signature_len = mavlink20.MAVLINK_SIGNATURE_BLOCK_LEN; + } else { + signature_len = 0; + } + + // header's declared len compared to packets actual len + var actual_len = (msgbuf.length - (header_len + 2 + signature_len)); + var actual_len_nosign = (msgbuf.length - (header_len + 2 )); + + if ((mlen == actual_len) && (signature_len > 0)){ + var len_if_signed = mlen+signature_len; + //console.log("Packet appears signed && labeled as signed, OK. msgId=" + msgId); + + } else if ((mlen == actual_len_nosign) && (signature_len > 0)){ + + var len_if_signed = mlen+signature_len; + throw new Error("Packet appears unsigned when labeled as signed. Got actual_len "+actual_len_nosign+" expected " + len_if_signed + ", msgId=" + msgId); + + } else if( mlen != actual_len) { + throw new Error("Invalid MAVLink message length. Got " + (msgbuf.length - (header_len + 2)) + " expected " + mlen + ", msgId=" + msgId); + + } + + if (!(msgId in mavlink20.map)) { + throw new Error("Unknown MAVLink message ID (" + msgId + ")"); + } + + // here's the common chunks of packet we want to work with below.. + var payloadBuf = msgbuf.slice(mavlink20.HEADER_LEN, -(signature_len+2)); // the remaining bit between the header and the crc + var crcCheckBuf = msgbuf.slice(1, -(signature_len+2)); // the part uses to calculate the crc - ie between the magic and signature, + + // decode the payload + // refs: (fmt, type, order_map, crc_extra) = mavlink20.map[msgId] + var decoder = mavlink20.map[msgId]; + + // decode the checksum + var receivedChecksum = undefined; + if ( signature_len == 0 ) { // unsigned + try { + var crcBuf1 = msgbuf.slice(-2); + receivedChecksum = jspack.Unpack(' payloadBuf.length) { + payloadBuf = this.concat_buffer(payloadBuf, new Uint8Array(paylen - payloadBuf.length).fill(0)); + } + // Decode the payload and reorder the fields to match the order map. + try { + var t = jspack.Unpack(decoder.format, payloadBuf); + } + catch (e) { + throw new Error('Unable to unpack MAVLink payload type='+decoder.type+' format='+decoder.format+' payloadLength='+ payloadBuf +': '+ e.message); + } + + // Need to check if the message contains arrays + var args = {}; + const elementsInMsg = decoder.order_map.length; + const actualElementsInMsg = JSON.parse(JSON.stringify(t)).length; + + if (elementsInMsg == actualElementsInMsg) { + // Reorder the fields to match the order map + t.forEach(function(e, i, l) { + args[i] = t[decoder.order_map[i]] + }); + } else { + // This message contains arrays + var typeIndex = 1; + var orderIndex = 0; + var memberIndex = 0; + var tempArgs = {}; + + // Walk through the fields + for(var i = 0, size = decoder.format.length-1; i <= size; ++i) { + var order = decoder.order_map[orderIndex]; + var currentType = decoder.format[typeIndex]; + + if (isNaN(parseInt(currentType))) { + // This field is not an array check the type and add it to the args + tempArgs[orderIndex] = t[memberIndex]; + memberIndex++; + } else { + // This field is part of an array, need to find the length of the array + var arraySize = '' + var newArray = [] + while (!isNaN(decoder.format[typeIndex])) { + arraySize = arraySize + decoder.format[typeIndex]; + typeIndex++; + } + + // Now that we know how long the array is, create an array with the values + for(var j = 0, size = parseInt(arraySize); j < size; ++j){ + newArray.push(t[j+orderIndex]); + memberIndex++; + } + + // Add the array to the args object + arraySize = arraySize + decoder.format[typeIndex]; + currentType = arraySize; + tempArgs[orderIndex] = newArray; + } + orderIndex++; + typeIndex++; + } + + // Finally reorder the fields to match the order map + t.forEach(function(e, i, l) { + args[i] = tempArgs[decoder.order_map[i]] + }); + } + + // construct the message object + try { + // args at this point might look like: { '0': 6, '1': 8, '2': 0, '3': 0, '4': 3, '5': 3 } + var m = new decoder.type(); // make a new 'empty' instance of the right class, + m.set(args,false); // associate ordered-field-numbers to names, after construction not during. + } + catch (e) { + throw new Error('Unable to instantiate MAVLink message of type '+decoder.type+' : ' + e.message); + } + + m._signed = sig_ok; + if (m._signed) { m._link_id = msgbuf[-13]; } + + m._msgbuf = msgbuf; + m._payload = payloadBuf; + m.crc = receivedChecksum; + m._header = new mavlink20.header(msgId, mlen, seq, srcSystem, srcComponent, incompat_flags, compat_flags); + this.log(m); + return m; +} + + +// Browser and Node.js compatible module exports +if (!isNode) { + // For browsers, attach to window or use global namespace + if (typeof window !== 'undefined') { + window.mavlink20 = mavlink20; + window.MAVLink20Processor = MAVLink20Processor; + } + // Also support global assignment + if (typeof global !== 'undefined') { + global.mavlink20 = mavlink20; + global.MAVLink20Processor = MAVLink20Processor; + } +} else { + // For Node.js, use module.exports + if (typeof module === "object" && module.exports) { + module.exports = {mavlink20, MAVLink20Processor}; + } +} + diff --git a/modules/MAVLink/mavlink_store.js b/modules/MAVLink/mavlink_store.js new file mode 100644 index 00000000..f24a9b5e --- /dev/null +++ b/modules/MAVLink/mavlink_store.js @@ -0,0 +1,94 @@ +/* + MAVLink store of recently received messages + use the mavlink_store object to store and retrieve messages +*/ + +class MAVLinkMessageStore { + static _instance = null; // holds the one and only instance + + constructor() { + // return the existing instance if it already exists + if (MAVLinkMessageStore._instance) { + return MAVLinkMessageStore._instance; + } + + // first construction, do normal init + this.store = {}; + + // default system and component ids + this.sysid_default = 1; + this.compid_default = 1; + + // remember this instance for future constructions + MAVLinkMessageStore._instance = this; + } + + // optional helper, some teams prefer this call site + static getInstance() { + return new MAVLinkMessageStore(); + } + + // store message in the message store + // indexed by system id, component id and message id + store_message(msg) { + // sanity check msg + if ( + msg == null || + msg._id == null || + msg._header == null || + msg._header.srcSystem == null || + msg._header.srcComponent == null + ) { + return; + } + + // retrieve sender system id and component id + const sysid = msg._header.srcSystem; + const compid = msg._header.srcComponent; + + // store message + if (this.store[sysid] == null) { + this.store[sysid] = {}; + } + if (this.store[sysid][compid] == null) { + this.store[sysid][compid] = {}; + } + this.store[sysid][compid][msg._id] = msg; + } + + // retrieve latest message, returns null if not found + get_latest_message(msgid, sysid = this.sysid_default, compid = this.compid_default) { + if (this.store[sysid] == null) return null; + if (this.store[sysid][compid] == null) return null; + return this.store[sysid][compid][msgid]; + } + + find_message_by_name(name, sysid = this.sysid_default, compid = this.compid_default) { + if (!this.store[sysid] || !this.store[sysid][compid]) return null; + for (const id in this.store[sysid][compid]) { + const msg = this.store[sysid][compid][id]; + if (msg._name === name) return msg; + } + return null; + } + + // get available message names, returns array or null + get_available_message_names(sysid = this.sysid_default, compid = this.compid_default) { + if (this.store[sysid] == null) return null; + if (this.store[sysid][compid] == null) return null; + + const msg_ids = Object.keys(this.store[sysid][compid]); + if (msg_ids.length === 0) return null; + + const msg_names = msg_ids.map(id => this.store[sysid][compid][id]._name); + if (msg_names.length === 0) return null; + return msg_names; + } +} + +// export the class +export default MAVLinkMessageStore; + +// export the singleton instance +const mavlink_store = MAVLinkMessageStore.getInstance(); +export { mavlink_store };