Commit 649249ca authored by Léo-Paul Géneau's avatar Léo-Paul Géneau 👾 Committed by Thomas Gambier

component/qjs-wrapper: v↑ qjs-wrapper (1.3 -> 2.0)

component/mavsdk: v↑ mavsdk (0.39.0 -> 1.4.13)
component/gwsocket: add gwsocket websocket server
software/js-drone: add frontend for subscriber
software/js-drone: use WebSocket for subscriber
slapos/software: run quickjs as a service
parent be75d825
Pipeline #33429 passed with stage
in 0 seconds
# simple, standalone, language-agnostic, RFC6455 compliant WebSocket Server, written in C. https://gwsocket.io
[buildout]
parts = gwsocket
[gwsocket]
recipe = slapos.recipe.cmmi
shared = true
url = https://tar.gwsocket.io/gwsocket-0.4.tar.gz
md5sum = 1367e77c47cb6379025e64deb85fb066
...@@ -4,37 +4,56 @@ extends = ...@@ -4,37 +4,56 @@ extends =
../curl/buildout.cfg ../curl/buildout.cfg
../git/buildout.cfg ../git/buildout.cfg
../jsoncpp/buildout.cfg ../jsoncpp/buildout.cfg
../lxml-python/buildout.cfg
../macros/macro.pythonpath.eggs.cfg
../tinyxml2/buildout.cfg ../tinyxml2/buildout.cfg
../zlib/buildout.cfg ../zlib/buildout.cfg
parts = parts =
mavsdk mavsdk
[c-astral-headers]
recipe = slapos.recipe.build:gitclone
repository = https://lab.nexedi.com/nexedi/c-astral-c-library
revision = v1.0
git-executable = ${git:location}/bin/git
[gcc] [gcc]
min_version = 7.1 min_version = 7.1
[c-astral-xml-definition]
recipe = slapos.recipe.build:gitclone
repository = https://lab.nexedi.com/nexedi/c-astral-c-library.git
revision = v2.1
git-executable = ${git:location}/bin/git
[mavsdk-source] [mavsdk-source]
recipe = slapos.recipe.build:gitclone recipe = slapos.recipe.build:gitclone
repository = https://github.com/mavlink/MAVSDK.git repository = https://github.com/mavlink/MAVSDK.git
revision = v0.39.0 revision = v1.4.13
git-executable = ${git:location}/bin/git git-executable = ${git:location}/bin/git
ignore-cloning-submodules = true ignore-cloning-submodules = true
[future]
recipe = zc.recipe.egg:custom
egg = future
[mavsdk-env]
CMAKE_INCLUDE_PATH=${curl:location}/include:${jsoncpp:location}/include:${tinyxml2:location}/include
CMAKE_LIBRARY_PATH=${curl:location}/lib:${jsoncpp:location}/lib:${tinyxml2:location}/lib:${zlib:location}/lib
CMAKE_PROGRAM_PATH=${cmake:location}/bin
PATH=${pkgconfig:location}/bin/:${git:location}/bin/:%(PATH)s
LDFLAGS=-L${curl:location}/lib -Wl,-rpath=${curl:location}/lib -L${jsoncpp:location}/lib -Wl,-rpath=${jsoncpp:location}/lib -L${tinyxml2:location}/lib -Wl,-rpath=${tinyxml2:location}/lib -L${zlib:location}/lib -Wl,-rpath=${zlib:location}/lib -Wl,-rpath=@@LOCATION@@/lib
[mavsdk-pythonpath]
<= macro.pythonpath.eggs
environment = mavsdk-env
eggs =
${future:egg}
${lxml-python:egg}
[mavsdk] [mavsdk]
recipe = slapos.recipe.cmmi recipe = slapos.recipe.cmmi
path = ${mavsdk-source:location} path = ${mavsdk-source:location}
cmake = ${cmake:location}/bin/cmake cmake = ${cmake:location}/bin/cmake
depends = ${mavsdk-pythonpath:recipe}
pre-configure = pre-configure =
${git:location}/bin/git submodule update --init --recursive ${git:location}/bin/git submodule update --init --recursive
cp -r ${c-astral-headers:location}/* ${mavsdk-source:location}/src/third_party/mavlink/include/mavlink/v2.0/ sed -i 's#message_definitions/v1.0#${c-astral-xml-definition:location}#' ${mavsdk-source:location}/third_party/mavlink/CMakeLists.txt
sed -i 's#common/mavlink.h#CAstral/mavlink.h#' ${mavsdk-source:location}/src/core/mavlink_include.h
configure-command = configure-command =
${:cmake} ${:cmake}
configure-options = configure-options =
...@@ -42,19 +61,23 @@ configure-options = ...@@ -42,19 +61,23 @@ configure-options =
-DCMAKE_C_FLAGS="${:CMAKE_CFLAGS}" -DCMAKE_C_FLAGS="${:CMAKE_CFLAGS}"
-DCMAKE_CXX_FLAGS="${:CMAKE_CFLAGS}" -DCMAKE_CXX_FLAGS="${:CMAKE_CFLAGS}"
-DCMAKE_INSTALL_PREFIX=@@LOCATION@@ -DCMAKE_INSTALL_PREFIX=@@LOCATION@@
-DCMAKE_INSTALL_RPATH=${:CMAKE_LIBRARY_PATH}:@@LOCATION@@/lib -DCMAKE_INSTALL_RPATH=${mavsdk-env:CMAKE_LIBRARY_PATH}:@@LOCATION@@/lib
-DPKG_CONFIG_EXECUTABLE=${pkgconfig:location}/bin/pkg-config -DPKG_CONFIG_EXECUTABLE=${pkgconfig:location}/bin/pkg-config
-DSUPERBUILD=OFF -DSUPERBUILD=OFF
-Bbuild/default -Bbuild/default
-H. -H.
-Wno-dev
make-binary = make-binary =
${:cmake} --build build/default --target install ${:cmake} --build build/default --target install
environment = environment = mavsdk-env
CMAKE_INCLUDE_PATH=${curl:location}/include:${jsoncpp:location}/include:${tinyxml2:location}/include
CMAKE_LIBRARY_PATH=${:CMAKE_LIBRARY_PATH}
CMAKE_PROGRAM_PATH=${cmake:location}/bin
PATH=${pkgconfig:location}/bin/:%(PATH)s
LDFLAGS=-L${curl:location}/lib -Wl,-rpath=${curl:location}/lib -L${jsoncpp:location}/lib -Wl,-rpath=${jsoncpp:location}/lib -L${tinyxml2:location}/lib -Wl,-rpath=${tinyxml2:location}/lib -L${zlib:location}/lib -Wl,-rpath=${zlib:location}/lib -Wl,-rpath=@@LOCATION@@/lib
CMAKE_CFLAGS=-I${tinyxml2:location}/include CMAKE_CFLAGS=-I${tinyxml2:location}/include
CMAKE_LIBRARY_PATH=${curl:location}/lib:${jsoncpp:location}/lib:${tinyxml2:location}/lib:${zlib:location}/lib
[c-astral-wrapper]
recipe = slapos.recipe.cmmi
configure-command = true
url = https://lab.nexedi.com/nexedi/c-astral-wrapper/-/archive/v2.0/c-astral-wrapper-v2.0.tar.gz
md5sum = ee2d05d225a57d17318282ff595fd498
environment =
CPLUS_INCLUDE_PATH=${qjs-wrapper-source:location}/include:${mavsdk:location}/include:${mavsdk:location}/include/mavsdk
LDFLAGS=-L${mavsdk:location}/lib -Wl,-rpath=${mavsdk:location}/lib
[buildout] [buildout]
extends = extends =
../git/buildout.cfg
../mavsdk/buildout.cfg ../mavsdk/buildout.cfg
../open62541/buildout.cfg ../open62541/buildout.cfg
../quickjs/buildout.cfg ../quickjs/buildout.cfg
parts = qjs-wrapper parts = qjs-wrapper
[qjs-wrapper-source]
recipe = slapos.recipe.build:gitclone
repository = https://lab.nexedi.com/nexedi/qjs-wrapper.git
revision = v2.0
git-executable = ${git:location}/bin/git
[qjs-wrapper] [qjs-wrapper]
recipe = slapos.recipe.cmmi recipe = slapos.recipe.cmmi
configure-command = true configure-command = true
url = https://lab.nexedi.com/nexedi/qjs-wrapper/-/archive/v1.3/qjs-wrapper-v1.3.tar.gz path = ${qjs-wrapper-source:location}
md5sum = 5f63356c6a10bf227e2641ea4f78c7a2
environment = environment =
C_INCLUDE_PATH=include:${open62541:location}/include:${open62541:location}/deps:${open62541:location}/src/pubsub:${quickjs:location}/include C_INCLUDE_PATH=include:${open62541:location}/include:${open62541:location}/deps:${open62541:location}/src/pubsub:${quickjs:location}/include
CPLUS_INCLUDE_PATH=include:${mavsdk:location}/include:${mavsdk:location}/include/mavsdk LDFLAGS=-L${open62541:location}/lib -Wl,-rpath=${open62541:location}/lib -L${c-astral-wrapper:location}/lib -Wl,-rpath=${c-astral-wrapper:location}/lib
LDFLAGS=-L${open62541:location}/lib -Wl,-rpath=${open62541:location}/lib -L${mavsdk:location}/lib -Wl,-rpath=${mavsdk:location}/lib
# Javascript drone # # Javascript drone #
## Presentation ## ## Presentation ##
* Deploy `user.js` flight script on a drone swarm * Deploy `user.js` flight script on a drone swarm
* Deploy a GUI on subscribers
* Run the flight script or the GUI as a SlapOS service
* Compile all required libraries to run the flight script
## Parameters ## ## Parameters ##
* autopilot-ip: IPv4 address to identify the autopilot from the companion board * autopilotIp: IPv4 address to identify the autopilot from the companion board
* droneGuidList: List of computer id on which flight script must be deployed
* isASimulation: Must be set to 'true' to automatically take off during simulation
* multicastIp: IPv6 of the multicast group of the swarm
* netIf: Network interface used for multicast traffic
* flightScript: URL of user's script to execute to fly drone swarm
* subscriberGuidList: List of computer id on which a GUI must be deployed
* drone-guid-list: List of computer id on which flight script must be deployed ## How it works ##
* is-a-simulation: Must be set to 'true' to automatically take off during simulation For each computer listed in `droneGuidList` and `subscriberGuidList` the `peer` SR type will be instanciated.
* multicast-ip: IPv6 of the multicast group of the swarm Each instance will return a `instance-path`. Under this path one will find `quickjs binary` in `bin` folder
and `scripts` in `etc` folder. Subcribers also return a `httpd-url` (the GUI address) and a `websocket-url` (used by the
GUI).
* net-if: Network interface used for multicast traffic `quickjs binary location` `scripts location`/main.js `scripts location`/user.js is run as a SlapOS service. This allows
each instance to communicate with the others through OPC-UA pub/sub. For the drones it also establishes a connexion with
the UAV autopilot, for a subscriber it sends the pub/sub messages through the websocket.
* flight-script: URL of user's script to execute to fly drone swarm
* subscriber-guid-list: List of computer id on which subscription script must be deployed ## Web GUI (subcribers)
## How it works ##
For each computer listed in `drone-guid-list` and `subscriber-guid-list` a drone SR will be instanciated. ### Drones informations
Each instance will return a `instance-path`. Under this path one will find `quickjs binary` in `bin` folder
and `scripts` in `etc` folder. For each drone is displayed:
Run `quickjs binary location` `scripts location`/main.js `scripts location`/user.js . * the user script and autopilot logs
* the flight state (ready, flying, landing)
* the latitude in degrees
* the longitude in degrees
* the relative altitude in meters
* the yaw angle in degrees
* the speed (ground speed for multicopters, airspeed for fixed wings) in meters per second
* the climb rate in meters per second
### Buttons
* Start: sends a "start" message to the swarm and changes into a stop button
* Stop: sends a "stop" message to the swarm
* Switch leader: sends a "switch" message to the swarm, it is usually used to change the leader
* Quit: exits (closes websocket and stops pub/sub)
![GUI screenshot](images/js-drone_GUI_screenshot.png)
...@@ -12,26 +12,34 @@ ...@@ -12,26 +12,34 @@
# Substitution (${...:...}), extension ([buildout] extends = ...) and # Substitution (${...:...}), extension ([buildout] extends = ...) and
# section inheritance (< = ...) are NOT supported (but you should really # section inheritance (< = ...) are NOT supported (but you should really
# not need these here). # not need these here).
[index-html]
_update_hash_filename_ = web-gui/index.html.jinja2
md5sum = 1eedc017ecc9d1a6761dc2fff3bbab9b
[instance-profile] [instance-profile]
filename = instance.cfg filename = instance.cfg.in
md5sum = 360b58007c25727b7bd8a9154d5cafd4 md5sum = 80dae3e883663311d9814def78ee875a
[instance-default] [instance-default]
filename = instance-default.cfg filename = instance-default.cfg.jinja2
md5sum = 903939308701b11b1ff751784a9be110 md5sum = 9db922cc0fcaa67006a2d6b9b95b95fe
[instance-drone] [instance-peer]
filename = instance-drone.cfg filename = instance-peer.cfg.jinja2.in
md5sum = 1ff50063f5a54712a0bc0ff38fa74630 md5sum = d12fbb134c587173ddff46ff1bc6ffe7
[main] [main]
filename = main.js _update_hash_filename_ = drone-scripts/main.js.jinja2
md5sum = d0bfcc79cdd7c1e5b8f5d264cc59074e md5sum = 9a8ec8a2778f63789f39291795f47e98
[pubsub] [pubsub]
filename = pubsub.js _update_hash_filename_ = drone-scripts/pubsub.js.jinja2
md5sum = 1555496ad591a31a845f33488d5c335d md5sum = 1555496ad591a31a845f33488d5c335d
[script-js]
_update_hash_filename_ = web-gui/script.js.jinja2
md5sum = e28492276416c2d84e770217ae97a88f
[worker] [worker]
filename = worker.js _update_hash_filename_ = drone-scripts/worker.js.jinja2
md5sum = e4b4ca3bde1a21f1dbfc4ff7fa3b872c md5sum = 48540afedd5437129196d84832d2ed40
/*jslint nomen: true, indent: 2, maxerr: 3, maxlen: 80 */ /*jslint nomen: true, indent: 2, maxerr: 3, maxlen: 80 */
/*global arm, console, exit, open, scriptArgs, setTimeout, start, stop, /*global arm, console, close, dup2, exit, open, scriptArgs, setTimeout, start,
stopPubsub, takeOffAndWait, Worker*/ stop, stopPubsub, takeOffAndWait, Worker, SIGINT, SIGTERM*/
import { import {
arm, arm,
start, start,
...@@ -8,17 +8,27 @@ import { ...@@ -8,17 +8,27 @@ import {
stopPubsub, stopPubsub,
takeOffAndWait takeOffAndWait
} from {{ json_module.dumps(qjs_wrapper) }}; } from {{ json_module.dumps(qjs_wrapper) }};
import { setTimeout, Worker } from "os"; import {
import { open, exit } from "std"; Worker,
SIGTERM,
(function (arm, console, exit, open, scriptArgs, setTimeout, start, stop, dup2,
stopPubsub, takeOffAndWait, Worker) { setTimeout,
signal
} from "os";
import { err, exit, open, out } from "std";
(function (arm, console, dup2, err, exit, open, out, scriptArgs,
setTimeout, start, stop, stopPubsub, takeOffAndWait, Worker,
SIGTERM) {
"use strict"; "use strict";
var CONF_PATH = {{ json_module.dumps(configuration) }}, var CONF_PATH = {{ json_module.dumps(configuration) }},
conf_file = open(CONF_PATH, "r"), conf_file = open(CONF_PATH, "r"),
configuration = JSON.parse(conf_file.readAsString()), configuration = JSON.parse(conf_file.readAsString()),
LOG_FILE = "{{ log_dir }}/mavsdk-log", MAVSDK_LOG_FILE_PATH =
"{{ log_dir }}/mavsdk_" + new Date().toISOString() + ".log",
LOG_FILE =
open("{{ log_dir }}/quickjs_" + new Date().toISOString() + ".log", "w"),
pubsubWorker, pubsubWorker,
worker, worker,
user_script = scriptArgs[1], user_script = scriptArgs[1],
...@@ -28,6 +38,10 @@ import { open, exit } from "std"; ...@@ -28,6 +38,10 @@ import { open, exit } from "std";
conf_file.close(); conf_file.close();
// redirect stdout and stderr
dup2(LOG_FILE.fileno(), out.fileno());
dup2(LOG_FILE.fileno(), err.fileno());
// Use a Worker to ensure the user script // Use a Worker to ensure the user script
// does not block the main script // does not block the main script
// (preventing it to be stopped for example) // (preventing it to be stopped for example)
...@@ -37,17 +51,28 @@ import { open, exit } from "std"; ...@@ -37,17 +51,28 @@ import { open, exit } from "std";
worker = new Worker("{{ worker_script }}"); worker = new Worker("{{ worker_script }}");
function quit(is_a_drone, exit_code) { function quit(is_a_drone, exit_code) {
worker.onmessage = null;
stopPubsub(); stopPubsub();
if (is_a_drone) { if (is_a_drone) {
stop(); stop();
} }
LOG_FILE.close();
exit(exit_code); exit(exit_code);
} }
function exitWorker(exit_code) {
worker.postMessage({
type: "exit",
code: exit_code
});
}
signal(SIGTERM, exitWorker.bind(null, 0));
function exitOnFail(ret, msg) { function exitOnFail(ret, msg) {
if (ret) { if (ret) {
console.log(msg); console.log(msg);
quit(1); exitWorker(1);
} }
} }
...@@ -55,7 +80,12 @@ import { open, exit } from "std"; ...@@ -55,7 +80,12 @@ import { open, exit } from "std";
var address = configuration.autopilotIp + ":" + configuration.autopilotPort; var address = configuration.autopilotIp + ":" + configuration.autopilotPort;
console.log("Will connect to", address); console.log("Will connect to", address);
exitOnFail( exitOnFail(
start(configuration.autopilotIp, configuration.autopilotPort, LOG_FILE, 60), start(
configuration.autopilotIp,
configuration.autopilotPort,
MAVSDK_LOG_FILE_PATH,
60
),
"Failed to connect to " + address "Failed to connect to " + address
); );
} }
...@@ -87,7 +117,7 @@ import { open, exit } from "std"; ...@@ -87,7 +117,7 @@ import { open, exit } from "std";
// First argument must provide the user script path // First argument must provide the user script path
if (user_script === undefined) { if (user_script === undefined) {
console.log('Please provide the user_script path.'); console.log('Please provide the user_script path.');
quit(1); exitWorker(1);
} }
worker.postMessage({ worker.postMessage({
...@@ -138,14 +168,16 @@ import { open, exit } from "std"; ...@@ -138,14 +168,16 @@ import { open, exit } from "std";
// Start the update loop // Start the update loop
loop(); loop();
} else if (type === 'updated') { } else if (type === 'updated') {
err.flush();
out.flush();
can_update = true; can_update = true;
} else if (type === 'exited') { } else if (type === 'exited') {
worker.onmessage = null; worker.onmessage = null;
quit(configuration.isADrone, e.data.exit); quit(configuration.isADrone, e.data.exit);
} else { } else {
console.log('Unsupported message type', type); console.log('Unsupported message type', type);
quit(configuration.isADrone, 1); exitWorker(1);
} }
}; };
}(arm, console, exit, open, scriptArgs, setTimeout, start, stop, stopPubsub, }(arm, console, dup2, err, exit, open, out, scriptArgs, setTimeout, start, stop,
takeOffAndWait, Worker)); stopPubsub, takeOffAndWait, Worker, SIGTERM));
/*jslint nomen: true, indent: 2, maxerr: 3, maxlen: 80 */
/*global console, getAltitude, getAltitudeRel, getInitialAltitude, gpsIsOk,
getLatitude, getLongitude, getYaw, execUserScript, initPubsub, loiter,
setAirSpeed, setMessage, setTargetCoordinates, std, triggerParachute,
updateLogAndProjection, Drone, Worker*/
import {
Drone,
triggerParachute,
getAirspeed,
getAltitude,
getClimbRate,
getInitialAltitude,
gpsIsOk,
getPosition,
getYaw,
initPubsub,
isLanding,
loiter,
setAirSpeed,
setMessage,
setTargetCoordinates,
updateLogAndProjection
} from {{ json_module.dumps(qjs_wrapper) }};
import {
SIGTERM,
WNOHANG,
Worker,
close,
exec,
kill,
pipe,
setReadHandler,
waitpid
} from "os";
import { evalScript, fdopen, loadFile, open } from "std";
(function (Drone, SIGTERM, WNOHANG, Worker, close, console, evalScript, exec,
fdopen, getAltitude, getInitialAltitude, gpsIsOk, getPosition,
getYaw, initPubsub, kill, isLanding, loadFile, loiter, open, pipe,
setAirSpeed, setMessage, setReadHandler, setTargetCoordinates,
triggerParachute, updateLogAndProjection, waitpid) {
// Every script is evaluated per drone
"use strict";
var CONF_PATH = {{ json_module.dumps(configuration) }},
conf_file = open(CONF_PATH, "r"),
configuration = JSON.parse(conf_file.readAsString()),
clientId,
drone_dict = {},
gwsocket_pid,
gwsocket_r_pipe_fd,
gwsocket_w_pipe_fd,
handleWebSocketMessage,
last_message_timestamp = 0,
last_log_timestamp = 0,
parent = Worker.parent,
peer_dict = {},
user_me = {
//required to fly
triggerParachute: triggerParachute,
exit: exitWorker,
getDroneDict: function () { return drone_dict; },
getAltitudeAbs: getAltitude,
getCurrentPosition: getPosition,
getInitialAltitude: getInitialAltitude,
gpsIsOk: gpsIsOk,
getYaw: getYaw,
getSpeed: getAirspeed,
getClimbRate: getClimbRate,
id: configuration.id,
isLanding: isLanding,
loiter: loiter,
sendMsg: function (msg, id) {
if (id === undefined) { id = -1; }
setMessage(JSON.stringify({
content: msg,
timestamp: Date.now(),
dest_id: id
}));
},
setAirSpeed: setAirSpeed,
setTargetCoordinates: setTargetCoordinates
};
conf_file.close();
function exitWorker(exit_code) {
if (user_me.hasOwnProperty("onWebSocketMessage")) {
stopGwsocket();
}
parent.postMessage({type: "exited", exit: exit_code});
parent.onmessage = null;
}
function readMessage(rd) {
function read4() {
var b1, b2, b3, b4;
b1 = rd.getByte();
b2 = rd.getByte();
b3 = rd.getByte();
b4 = rd.getByte();
return (b1 << 24) | (b2 << 16) | (b3 << 8) | b4;
}
clientId = read4();
var type = read4();
var len = read4();
var data = new ArrayBuffer(len);
rd.read(data, 0, len);
return {
client: clientId,
type: type,
data: String.fromCharCode.apply(null, new Uint8Array(data)).trim()
};
}
function writeMessage(wr, m) {
function write4(v) {
wr.putByte((v >> 24) & 0xFF);
wr.putByte((v >> 16) & 0xFF);
wr.putByte((v >> 8) & 0xFF);
wr.putByte(v & 0xFF);
}
write4(m.client);
write4(m.type);
write4(m.data.byteLength);
wr.write(m.data, 0, m.data.byteLength);
wr.flush();
}
function runGwsocket(onMessage) {
var gwsocket_w_pipe = pipe(),
gwsocket_r_pipe = pipe();
gwsocket_pid = exec([
"gwsocket",
"--port=" + configuration.websocketPort,
"--addr=" + configuration.websocketIp,
"--std",
"--strict"
], {
block: false,
usePath: false,
file: {{ json_module.dumps(gwsocket_bin) }},
stdin: gwsocket_w_pipe[0],
stdout: gwsocket_r_pipe[1]
});
gwsocket_w_pipe_fd = fdopen(gwsocket_w_pipe[1], "w");
gwsocket_r_pipe_fd = fdopen(gwsocket_r_pipe[0], "r");
handleWebSocketMessage = function () {
var message = readMessage(gwsocket_r_pipe_fd).data;
if (message.includes(configuration.websocketIp)) {
return;
}
onMessage(message);
};
user_me.writeWebsocketMessage = function (message) {
var buf = new ArrayBuffer(message.length);
var bufView = new Uint8Array(buf);
for (var i=0; i<message.length; i++) {
bufView[i] = message.charCodeAt(i);
}
writeMessage(gwsocket_w_pipe_fd, {client: clientId, type: 1, data: buf});
}
setReadHandler(gwsocket_r_pipe[0], handleWebSocketMessage);
}
function stopGwsocket() {
handleWebSocketMessage = null;
close(gwsocket_w_pipe_fd);
close(gwsocket_r_pipe_fd);
kill(gwsocket_pid, SIGTERM);
waitpid(gwsocket_pid, WNOHANG);
}
function loadUserScript(path) {
var script_content = loadFile(path);
if (script_content === null) {
console.log("Failed to load user script " + path);
exitWorker(1);
}
try {
evalScript(
"function execUserScript(from, me) {" + script_content + "};"
);
} catch (e) {
console.log("Failed to evaluate user script", e);
exitWorker(1);
}
execUserScript(null, user_me);
if (user_me.hasOwnProperty("onWebSocketMessage")) {
runGwsocket(user_me.onWebSocketMessage);
}
// Call the drone onStart function
if (user_me.hasOwnProperty("onStart")) {
user_me.onStart();
}
}
function handleMainMessage(evt) {
var type = evt.data.type, message, peer_id;
switch (type) {
case "initPubsub":
initPubsub(configuration.numberOfDrone, configuration.numberOfSubscriber);
for (peer_id = 0; peer_id < configuration.numberOfDrone + configuration.numberOfSubscriber; peer_id++) {
peer_dict[peer_id] = new Drone(peer_id);
peer_dict[peer_id].init(peer_id);
if (peer_id < configuration.numberOfDrone) {
drone_dict[peer_id] = peer_dict[peer_id];
}
}
parent.postMessage({type: "initialized"});
break;
case "load":
loadUserScript(evt.data.path);
parent.postMessage({type: "loaded"});
break;
case "update":
Object.entries(peer_dict).forEach(function ([id, peer]) {
message = peer.message;
if (user_me.id !== Number(id) && message.length > 0) {
message = JSON.parse(message);
if (message.timestamp != last_message_timestamp &&
user_me.hasOwnProperty("onGetMsg") &&
[-1, user_me.id].includes(message.dest_id)) {
last_message_timestamp = message.timestamp;
user_me.onGetMsg(message.content);
}
}
});
// Call the drone onStart function
if (user_me.hasOwnProperty("onUpdate")) {
user_me.onUpdate(evt.data.timestamp);
}
if (evt.data.timestamp - last_log_timestamp >= 1000) {
updateLogAndProjection();
last_log_timestamp = evt.data.timestamp;
}
parent.postMessage({type: "updated"});
break;
case "exit":
exitWorker(evt.data.code);
break;
default:
throw new Error("Unsupported message type", type);
};
}
parent.onmessage = function (evt) {
try {
handleMainMessage(evt);
} catch (error) {
// Catch all potential bug to exit the main process
// if it occurs
console.log(error);
exitWorker(1);
}
};
}(Drone, SIGTERM, WNOHANG, Worker, close, console, evalScript, exec,
fdopen, getAltitude, getInitialAltitude, gpsIsOk, getPosition, getYaw,
initPubsub, isLanding, kill, loadFile, loiter, open, pipe, setAirSpeed,
setMessage, setReadHandler, setTargetCoordinates, triggerParachute,
updateLogAndProjection, waitpid));
{% set autopilot_ip = slapparameter_dict.get('autopilotIp', '192.168.27.1') -%} {% set parameter_dict = dict(default_parameter_dict, **parameter_dict) -%}
{% set autopilot_port = slapparameter_dict.get('autopilotPort', 7909) -%} {% set guid_list = parameter_dict['droneGuidList'] + parameter_dict['subscriberGuidList'] -%}
{% set flight_script = slapparameter_dict.get('flightScript', 'https://lab.nexedi.com/nexedi/flight-scripts/raw/master/default.js') -%}
{% set is_a_simulation = slapparameter_dict.get('isASimulation', False) -%}
{% set multicast_ip = slapparameter_dict.get('multicastIp', 'ff15::1111') -%}
{% set net_if = slapparameter_dict.get('netIf', 'eth0') -%}
{% set drone_guid_list = slapparameter_dict.get('droneGuidList', []) -%}
{% set subscriber_guid_list = slapparameter_dict.get('subscriberGuidList', []) -%}
{% set guid_list = drone_guid_list + subscriber_guid_list -%}
{% set nb_peer = len(guid_list) -%}
{% set drone_id_list = [] -%} {% set drone_id_list = [] -%}
{% set subscriber_id_list = [] -%} {% set subscriber_id_list = [] -%}
{% set part_list = ['publish-connection-information'] -%} {% set part_list = ['publish-connection-information'] -%}
{% for id, guid in enumerate(guid_list) -%} {% for id, guid in enumerate(guid_list) -%}
{% set request_drone_section_title = 'request-drone' ~ id -%} {% set request_peer_section_title = 'request-peer' ~ id -%}
{% do part_list.append(request_drone_section_title) %} {% do part_list.append(request_peer_section_title) %}
[{{ request_drone_section_title }}] [{{ request_peer_section_title }}]
<= slap-connection <= slap-connection
recipe = slapos.cookbook:request.serialised recipe = slapos.cookbook:request.serialised
name = Drone{{ id }} name = Peer{{ id }}
software-url = $${:software-release-url} software-url = ${:software-release-url}
software-type = drone software-type = peer
return = instance-path return = instance-path
sla-computer_guid = {{ guid }} sla-computer_guid = {{ guid }}
config-autopilotIp = {{ autopilot_ip }} config-autopilotIp = {{ parameter_dict['autopilotIp'] }}
config-autopilotPort = {{ dumps(autopilot_port) }} config-autopilotPort = {{ dumps(parameter_dict['autopilotPort']) }}
config-numberOfPeers = {{ dumps(nb_peer) }} config-numberOfDrone = {{ dumps(len(parameter_dict['droneGuidList'])) }}
config-numberOfSubscriber = {{ dumps(len(parameter_dict['subscriberGuidList'])) }}
config-id = {{ dumps(id) }} config-id = {{ dumps(id) }}
config-isASimulation = {{ dumps(is_a_simulation) }} config-isASimulation = {{ dumps(parameter_dict['isASimulation']) }}
{% if guid in drone_guid_list -%} {% if id < len(parameter_dict['droneGuidList']) -%}
{% do drone_id_list.append(id) %} {% do drone_id_list.append(id) %}
config-isADrone = {{ dumps(True) }} config-isADrone = {{ dumps(True) }}
config-flightScript = {{ flight_script }} config-flightScript = {{ parameter_dict['flightScript'] }}
{% else -%} {% else -%}
{% do subscriber_id_list.append(id) %} {% do subscriber_id_list.append(id) %}
config-isADrone = {{ dumps(False) }} config-isADrone = {{ dumps(False) }}
config-flightScript = https://lab.nexedi.com/nexedi/flight-scripts/raw/master/subscribe.js config-flightScript = https://lab.nexedi.com/nexedi/flight-scripts/-/raw/v2.0/subscribe.js
{% endif -%} {% endif -%}
config-multicastIp = {{ multicast_ip }} config-multicastIp = {{ parameter_dict['multicastIp'] }}
config-netIf = {{ net_if }} config-netIf = {{ parameter_dict['netIf'] }}
{% endfor %} {% endfor %}
[publish-connection-information] [publish-connection-information]
...@@ -50,6 +41,6 @@ subscriber-id-list = {{ dumps(subscriber_id_list) }} ...@@ -50,6 +41,6 @@ subscriber-id-list = {{ dumps(subscriber_id_list) }}
[buildout] [buildout]
parts = parts =
{%- for part in part_list %} {% for part in part_list %}
{{ part }} {{ part }}
{%- endfor -%} {% endfor %}
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
"title": "Port of the drone's autopilot", "title": "Port of the drone's autopilot",
"description": "Port on which autopilot service is running.", "description": "Port on which autopilot service is running.",
"type": "integer", "type": "integer",
"default": "7909" "default": 7909
}, },
"droneGuidList": { "droneGuidList": {
"title": "List of drones computer ID", "title": "List of drones computer ID",
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
"title": "Script's URL of the flight", "title": "Script's URL of the flight",
"description": "URL of the script which will be executed for the flight. This URL must be publicly accesible so that the drone can fetch the script.", "description": "URL of the script which will be executed for the flight. This URL must be publicly accesible so that the drone can fetch the script.",
"type": "string", "type": "string",
"default": "https://lab.nexedi.com/nexedi/flight-scripts/raw/master/default.js" "default": "https://lab.nexedi.com/nexedi/flight-scripts/-/raw/v2.0/default.js"
}, },
"subscriberGuidList": { "subscriberGuidList": {
"title": "List of subscribers computer ID", "title": "List of subscribers computer ID",
......
...@@ -14,9 +14,14 @@ ...@@ -14,9 +14,14 @@
"description": "Port on which autopilot service is running.", "description": "Port on which autopilot service is running.",
"type": "integer" "type": "integer"
}, },
"numberOfPeers": { "numberOfDrone": {
"title": "Number of Peers", "title": "Number of drone",
"description": "Number of drones and subscribers in the swarm", "description": "Number of drone in the swarm",
"type": "integer"
},
"numberOfSubscriber": {
"title": "Number of subscriber",
"description": "Number of subscriber of the swarm",
"type": "integer" "type": "integer"
}, },
"id": { "id": {
......
[buildout] [buildout]
parts = parts =
main qjs-launcher
symlink-quickjs-binary
publish-connection-information publish-connection-information
[directory] [directory]
recipe = slapos.cookbook:mkdirectory recipe = slapos.cookbook:mkdirectory
home = $${buildout:directory} home = $${buildout:directory}
bin = $${:home}/bin bin = $${:home}/bin
etc = $${:home}/etc etc = $${:home}/etc
srv = $${:home}/srv
var = $${:home}/var var = $${:home}/var
log = $${:var}/log log = $${:var}/log
public = $${:srv}/public
service = $${:etc}/service
[js-dynamic-template] [js-dynamic-template]
recipe = slapos.recipe.template:jinja2 recipe = slapos.recipe.template:jinja2
rendered = $${directory:etc}/$${:_buildout_section_name_}.js rendered = $${directory:etc}/$${:_buildout_section_name_}.js
template = ${buildout:directory}/$${:_buildout_section_name_}.js
extra-context = extra-context =
context = context =
import json_module json import json_module json
raw gwsocket_bin ${gwsocket:location}/bin/gwsocket
raw qjs_wrapper ${qjs-wrapper:location}/lib/libqjswrapper.so raw qjs_wrapper ${qjs-wrapper:location}/lib/libqjswrapper.so
raw configuration {{ configuration }} raw configuration {{ configuration }}
$${:extra-context} $${:extra-context}
[main] [main]
<= js-dynamic-template <= js-dynamic-template
template = ${main:target}
extra-context = extra-context =
key log_dir directory:log key log_dir directory:log
key pubsub_script pubsub:rendered key pubsub_script pubsub:rendered
...@@ -32,19 +37,58 @@ extra-context = ...@@ -32,19 +37,58 @@ extra-context =
[pubsub] [pubsub]
<= js-dynamic-template <= js-dynamic-template
template = ${pubsub:target}
[worker] [worker]
<= js-dynamic-template <= js-dynamic-template
template = ${worker:target}
[user]
recipe = slapos.recipe.build:download
url = {{ parameter_dict['flightScript'] }}
destination = $${directory:etc}/user.js
offline = false
[qjs-launcher]
recipe = slapos.cookbook:wrapper
wrapper-path = $${directory:service}/qjs-launcher
command-line = ${quickjs:location}/bin/qjs $${main:rendered} $${user:target}
[script-js]
recipe = slapos.recipe.template:jinja2
template = ${script-js:target}
rendered = $${directory:public}/script.js
websocket-url = [{{ ipv6 }}]:{{ websocket_port }}
context =
raw websocket_url $${:websocket-url}
[index-html]
recipe = slapos.recipe.template:jinja2
template = ${index-html:target}
rendered = $${directory:public}/index.html
context =
raw nb_drones {{ parameter_dict['numberOfDrone'] }}
[httpd-port]
recipe = slapos.cookbook:free_port
minimum = 8080
maximum = 8090
ip = {{ ipv6 }}
[symlink-quickjs-binary] [httpd]
recipe = slapos.recipe.build recipe = slapos.cookbook:simplehttpserver
binary-path = ${quickjs:location}/bin/qjs host = {{ ipv6 }}
target = $${directory:bin}/qjs port = $${httpd-port:port}
init = base-path = $${directory:public}
import os wrapper = $${directory:service}/http-server
if not os.path.exists(options['target']): log-file = $${directory:log}/httpd.log
os.symlink(options['binary-path'], options['target']) use-hash-url = false
depends = $${index-html:rendered}
[publish-connection-information] [publish-connection-information]
recipe = slapos.cookbook:publish.serialised recipe = slapos.cookbook:publish.serialised
instance-path = $${directory:home} instance-path = $${directory:home}
{% if not parameter_dict['isADrone'] -%}
httpd-url = [$${httpd:host}]:$${httpd:port}
websocket-url = ws://$${script-js:websocket-url}
{% endif -%}
...@@ -9,7 +9,7 @@ offline = true ...@@ -9,7 +9,7 @@ offline = true
[switch-softwaretype] [switch-softwaretype]
recipe = slapos.cookbook:switch-softwaretype recipe = slapos.cookbook:switch-softwaretype
default = instance-default:output default = instance-default:output
drone = instance-drone:output peer = instance-peer:output
RootSoftwareInstance = $${:default} RootSoftwareInstance = $${:default}
[slap-configuration] [slap-configuration]
...@@ -22,36 +22,58 @@ cert = $${slap_connection:cert_file} ...@@ -22,36 +22,58 @@ cert = $${slap_connection:cert_file}
[dynamic-template-base] [dynamic-template-base]
recipe = slapos.recipe.template:jinja2 recipe = slapos.recipe.template:jinja2
url = ${buildout:directory}/$${:_buildout_section_name_}.cfg output = $${buildout:directory}/$${:_buildout_section_name_}.cfg
output = $${buildout:directory}/$${:_buildout_section_name_} extra-context =
context =
jsonkey default_parameter_dict :default-parameters
key parameter_dict slap-configuration:configuration
$${:extra-context}
default-parameters =
{
"autopilotIp": "192.168.27.1",
"autopilotPort": 7909,
"flightScript": "https://lab.nexedi.com/nexedi/flight-scripts/-/raw/v2.0/default.js",
"isASimulation": false,
"multicastIp": "ff15::1111",
"netIf": "eth0",
"droneGuidList": [],
"subscriberGuidList":[]
}
[instance-default] [instance-default]
<= dynamic-template-base <= dynamic-template-base
url = ${instance-default:target}
extensions = jinja2.ext.do extensions = jinja2.ext.do
context =
key slapparameter_dict slap-configuration:configuration
[instance-drone] [directory]
<= dynamic-template-base recipe = slapos.cookbook:mkdirectory
context = home = $${buildout:directory}
key configuration drone-configuration:output etc = $${:home}/etc
key user-script user:destination
[drone-configuration] [gwsocket-port]
recipe = slapos.cookbook:free_port
minimum = 6789
maximum = 6799
ip = $${slap-configuration:ipv6-random}
[peer-configuration]
recipe = slapos.recipe.template:jinja2 recipe = slapos.recipe.template:jinja2
output = $${directory:etc}/configuration.json output = $${directory:etc}/configuration.json
extensions = jinja2.ext.do
context = context =
import json_module json import json_module json
key slapparameter_dict slap-configuration:configuration key websocket_ip gwsocket-port:ip
inline = {{ json_module.dumps(slapparameter_dict) }} key websocket_port gwsocket-port:port
key parameter_dict slap-configuration:configuration
inline =
{% do parameter_dict.__setitem__('websocketIp', websocket_ip) -%}
{% do parameter_dict.__setitem__('websocketPort', websocket_port) -%}
{{ json_module.dumps(parameter_dict) }}
[user] [instance-peer]
recipe = slapos.recipe.build:download <= dynamic-template-base
url = $${slap-configuration:configuration.flightScript} url = ${instance-peer:output}
destination = $${directory:etc}/user.js extra-context =
offline = false key configuration peer-configuration:output
key ipv6 slap-configuration:ipv6-random
[directory] key websocket_port gwsocket-port:port
recipe = slapos.cookbook:mkdirectory
home = $${buildout:directory}
etc = $${:home}/etc
...@@ -5,16 +5,18 @@ extends = ...@@ -5,16 +5,18 @@ extends =
[sqdr-source] [sqdr-source]
recipe = slapos.recipe.build:gitclone recipe = slapos.recipe.build:gitclone
repository = https://lab.nexedi.com/slaposdrone/squadrone.git repository = https://lab.nexedi.com/slaposdrone/squadrone.git
revision = v1.0 revision = v2.0
git-executable = ${git:location}/bin/git git-executable = ${git:location}/bin/git
[qjs-wrapper] [sqdr-wrapper]
recipe = slapos.recipe.cmmi recipe = slapos.recipe.cmmi
configure-command = true configure-command = true
url =
path = ${sqdr-source:location} path = ${sqdr-source:location}
md5sum = environment =
CPLUS_INCLUDE_PATH=include:${qjs-wrapper-source:location}/include
LDFLAGS=-L${sqdr-source:location}/lib -Wl,-rpath=${sqdr-source:location}/lib
[qjs-wrapper]
environment = environment =
C_INCLUDE_PATH=include:${open62541:location}/include:${open62541:location}/deps:${open62541:location}/src/pubsub:${quickjs:location}/include C_INCLUDE_PATH=include:${open62541:location}/include:${open62541:location}/deps:${open62541:location}/src/pubsub:${quickjs:location}/include
CPLUS_INCLUDE_PATH=include LDFLAGS=-L${open62541:location}/lib -Wl,-rpath=${open62541:location}/lib -L${sqdr-wrapper:location}/lib -Wl,-rpath=${sqdr-wrapper:location}/lib
LDFLAGS=-L${open62541:location}/lib -Wl,-rpath=${open62541:location}/lib -L${sqdr-source:location}/lib -Wl,-rpath=${sqdr-source:location}/lib
...@@ -3,42 +3,43 @@ extends = ...@@ -3,42 +3,43 @@ extends =
buildout.hash.cfg buildout.hash.cfg
../../stack/slapos.cfg ../../stack/slapos.cfg
../../component/qjs-wrapper/buildout.cfg ../../component/qjs-wrapper/buildout.cfg
../../component/gwsocket/buildout.cfg
parts = parts =
instance-profile instance-profile
instance-default
instance-drone
main
pubsub
worker
slapos-cookbook slapos-cookbook
[instance-profile] [instance-default]
recipe = slapos.recipe.template recipe = slapos.recipe.build:download
url = ${:_profile_base_location_}/${:filename} url = ${:_profile_base_location_}/${:filename}
output = ${buildout:directory}/template.cfg
[jinja-template-base] [template-base]
recipe = slapos.recipe.template recipe = slapos.recipe.template
url = ${:_profile_base_location_}/${:_buildout_section_name_}.cfg url = ${:_profile_base_location_}/${:filename}
output = ${buildout:directory}/${:_buildout_section_name_}.cfg
[instance-default] [instance-peer]
<= jinja-template-base <= template-base
output = ${buildout:directory}/${:_buildout_section_name_}
[instance-drone] [instance-profile]
<= jinja-template-base <= template-base
output = ${buildout:directory}/template.cfg
[download-file-base] [download]
recipe = slapos.recipe.build:download recipe = slapos.recipe.build:download
url = ${:_profile_base_location_}/${:filename} url = ${:_profile_base_location_}/${:_update_hash_filename_}
destination = ${buildout:directory}/${:filename}
[index-html]
<= download
[main] [main]
<= download-file-base <= download
[pubsub] [pubsub]
<= download-file-base <= download
[script-js]
<= download
[worker] [worker]
<= download-file-base <= download
...@@ -12,11 +12,11 @@ ...@@ -12,11 +12,11 @@
"index": 0 "index": 0
}, },
"drone": { "drone": {
"title": "Drone", "title": "Peer",
"software-type": "drone", "software-type": "peer",
"description": "Drone Instance", "description": "Peer Instance",
"request": "instance-drone-input-schema.json", "request": "instance-peer-input-schema.json",
"response": "instance-drone-output-schema.json", "response": "instance-peer-output-schema.json",
"index": 1 "index": 1
} }
} }
......
...@@ -43,7 +43,8 @@ setup(name=name, ...@@ -43,7 +43,8 @@ setup(name=name,
install_requires=[ install_requires=[
'slapos.core', 'slapos.core',
'slapos.libnetworkcache', 'slapos.libnetworkcache',
'erp5.util' 'erp5.util',
'websocket-client',
], ],
zip_safe=True, zip_safe=True,
test_suite='test', test_suite='test',
......
...@@ -30,12 +30,11 @@ import json ...@@ -30,12 +30,11 @@ import json
import os import os
import socket import socket
import struct import struct
import subprocess
import time import time
import websocket
from slapos.testing.testcase import makeModuleSetUpAndTestCaseClass from slapos.testing.testcase import makeModuleSetUpAndTestCaseClass
MAIN_SCRIPT_NAME = 'main.js'
''' '''
0. positionArray 0. positionArray
0.1 latitude 0.1 latitude
...@@ -52,7 +51,6 @@ MONITORED_ITEM_NB = 3 ...@@ -52,7 +51,6 @@ MONITORED_ITEM_NB = 3
OPC_UA_PORT = 4840 OPC_UA_PORT = 4840
OPC_UA_NET_IF = 'lo' OPC_UA_NET_IF = 'lo'
MCAST_GRP = 'ff15::1111' MCAST_GRP = 'ff15::1111'
USER_SCRIPT_NAME = 'user.js'
# OPC UA Pub/Sub related constants # OPC UA Pub/Sub related constants
VERSION = 1 VERSION = 1
...@@ -98,63 +96,47 @@ UA_DATETIME_UNIX_EPOCH = 11644473600 * UA_DATETIME_SEC ...@@ -98,63 +96,47 @@ UA_DATETIME_UNIX_EPOCH = 11644473600 * UA_DATETIME_SEC
CONFIG_VERSION_MAJOR_VERSION = 1690792766 CONFIG_VERSION_MAJOR_VERSION = 1690792766
CONFIG_VERSION_MINOR_VERSION = 1690781976 CONFIG_VERSION_MINOR_VERSION = 1690781976
POSITION_ARRAY_TYPE = 11 #double POSITION_ARRAY_TYPE = 8 #int64
POSITION_ARRAY_VALUES = (45.64, 14.25, 686.61, 91.24) POSITION_ARRAY_INPUT_VALUES = (456400000, 142500000, 686000, 91000, 1697878907)
POSITION_ARRAY_OUTPUT_COEFS = (1e7, 1e7, 1000, 1000)
POSITION_ARRAY_OUTPUT_VALUES = tuple(value / coef for value, coef in zip(POSITION_ARRAY_INPUT_VALUES[:-1], POSITION_ARRAY_OUTPUT_COEFS))
SPEED_ARRAY_TYPE = 10 #float SPEED_ARRAY_TYPE = 10 #float
SPEED_ARRAY_VALUES = (-72.419998, 15.93, -0.015) SPEED_ARRAY_VALUES = (-72.419998, 15.93, -0.015)
STRING_TYPE = 12 STRING_TYPE = 12
TEST_MESSAGE = b'{"content":"{\\"next_checkpoint\\":1}","dest_id":-1}' MESSAGE_CONTENT = b'{\\"next_checkpoint\\":1}'
TEST_MESSAGE = b'{"content":"' + MESSAGE_CONTENT + b'","dest_id":-1}'
setUpModule, SlapOSInstanceTestCase = makeModuleSetUpAndTestCaseClass( setUpModule, SlapOSInstanceTestCase = makeModuleSetUpAndTestCaseClass(
os.path.abspath( os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', 'software.cfg'))) os.path.join(os.path.dirname(__file__), '..', 'software.cfg')))
class JSDroneTestCase(SlapOSInstanceTestCase): class SubscriberTestCase(SlapOSInstanceTestCase):
@classmethod @classmethod
def getInstanceParameterDict(cls): def getInstanceParameterDict(cls):
return { return {
'_': json.dumps({ '_': json.dumps({
'droneGuidList': [cls.slap._computer_id],
'netIf': OPC_UA_NET_IF, 'netIf': OPC_UA_NET_IF,
'subscriberGuidList': [cls.slap._computer_id], 'subscriberGuidList': [cls.slap._computer_id],
}) })
} }
def get_partition(self, instance_type): def get_partition(self, partition_id):
software_url = self.getSoftwareURL() software_url = self.getSoftwareURL()
for computer_partition in self.slap.computer.getComputerPartitionList(): for computer_partition in self.slap.computer.getComputerPartitionList():
partition_url = computer_partition.getSoftwareRelease()._software_release if computer_partition.getId() == partition_id:
partition_type = computer_partition.getType()
if partition_url == software_url and partition_type == instance_type:
return computer_partition return computer_partition
raise Exception("JS-drone %s partition not found" % instance_type) raise Exception("Partition %s not found" % partition_id)
def setUp(self): def setUp(self):
super().setUp() super().setUp()
subscriber_partition = self.get_partition('drone') subscriber_partition = self.get_partition('SubscriberTestCase-2')
instance_path = json.loads( self.websocket_server_address = json.loads(
subscriber_partition.getConnectionParameterDict()['_'])['instance-path'] subscriber_partition.getConnectionParameterDict()['_'])['websocket-url']
quickjs_bin = os.path.join(instance_path, 'bin', 'qjs') time.sleep(0.5)
script_dir = os.path.join(instance_path, 'etc')
self.qjs_process = subprocess.Popen(
[
quickjs_bin,
os.path.join(script_dir, MAIN_SCRIPT_NAME),
os.path.join(script_dir, USER_SCRIPT_NAME),
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
time.sleep(0.1)
def tearDown(self):
if self.qjs_process.returncode == None:
self.qjs_process.kill()
self.qjs_process.communicate()
super().tearDown()
def ua_networkMessage_encodeHeader(self): def ua_networkMessage_encodeHeader(self):
ua_byte1 = int(VERSION) ua_byte1 = int(VERSION)
...@@ -224,8 +206,8 @@ class JSDroneTestCase(SlapOSInstanceTestCase): ...@@ -224,8 +206,8 @@ class JSDroneTestCase(SlapOSInstanceTestCase):
data_set_message += struct.pack('H', MONITORED_ITEM_NB) data_set_message += struct.pack('H', MONITORED_ITEM_NB)
data_set_message += self.ua_array_encode( data_set_message += self.ua_array_encode(
POSITION_ARRAY_TYPE, POSITION_ARRAY_TYPE,
'd', 'q',
POSITION_ARRAY_VALUES, POSITION_ARRAY_INPUT_VALUES,
) )
data_set_message += self.ua_array_encode( data_set_message += self.ua_array_encode(
SPEED_ARRAY_TYPE, SPEED_ARRAY_TYPE,
...@@ -244,25 +226,39 @@ class JSDroneTestCase(SlapOSInstanceTestCase): ...@@ -244,25 +226,39 @@ class JSDroneTestCase(SlapOSInstanceTestCase):
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
s.sendto(ua_message, ('::1', OPC_UA_PORT)) s.sendto(ua_message, ('::1', OPC_UA_PORT))
def test_process(self):
expected_process_name_list = [
'qjs-launcher-on-watch',
'http-server-on-watch',
]
with self.slap.instance_supervisor_rpc as supervisor:
process_names = [process['name']
for process in supervisor.getAllProcessInfo()]
for expected_process_name in expected_process_name_list:
self.assertIn(expected_process_name, process_names)
def test_requested_instances(self): def test_requested_instances(self):
connection_parameter_dict = json.loads( connection_parameter_dict = json.loads(
self.computer_partition.getConnectionParameterDict()['_']) self.computer_partition.getConnectionParameterDict()['_'])
self.assertEqual(connection_parameter_dict['drone-id-list'], []) self.assertEqual(connection_parameter_dict['drone-id-list'], [0])
self.assertEqual(connection_parameter_dict['subscriber-id-list'], [0]) self.assertEqual(connection_parameter_dict['subscriber-id-list'], [1])
def test_subscriber_instance_parameter_dict(self): def test_subscriber_instance_parameter_dict(self):
self.assertEqual( self.assertEqual(
json.loads(self.get_partition('drone').getInstanceParameterDict()['_']), json.loads(self.get_partition('SubscriberTestCase-2').getInstanceParameterDict()['_']),
{ {
'autopilotIp': '192.168.27.1', 'autopilotIp': '192.168.27.1',
'autopilotPort': 7909, 'autopilotPort': 7909,
'id': 0, 'numberOfDrone': 1,
'numberOfSubscriber': 1,
'id': 1,
'isASimulation': False, 'isASimulation': False,
'isADrone': False, 'isADrone': False,
'flightScript': 'https://lab.nexedi.com/nexedi/flight-scripts/raw/master/subscribe.js', 'flightScript': 'https://lab.nexedi.com/nexedi/flight-scripts/-/raw/v2.0/subscribe.js',
'multicastIp': MCAST_GRP, 'netIf': OPC_UA_NET_IF,
'numberOfPeers': 1, 'multicastIp': MCAST_GRP
'netIf': OPC_UA_NET_IF
} }
) )
...@@ -281,14 +277,32 @@ class JSDroneTestCase(SlapOSInstanceTestCase): ...@@ -281,14 +277,32 @@ class JSDroneTestCase(SlapOSInstanceTestCase):
self.assertIn(expected_string, f.readlines()) self.assertIn(expected_string, f.readlines())
def test_pubsub_subscription(self): def test_pubsub_subscription(self):
ws = websocket.WebSocket()
ws.connect(self.websocket_server_address, timeout=5)
# Check if first message is 'Unknown instruction IP' where IP is client IPv6 address
self.assertIn(
b'Unknown instruction %s' % ws.sock.getsockname()[0].encode(),
ws.recv_frame().data
)
self.assertEqual(
ws.recv_frame().data,
b''.join((
b'{"drone_dict":{"0":{"latitude":',
b'"%.6f","longitude":"%.6f","altitude":"%.2f",' % (0, 0, 0),
b'"yaw":"%.2f","speed":"%.2f","climbRate":"%.2f",' % (0, 0, 0),
b'"timestamp":%d}}}' % 0,
))
)
self.send_ua_networkMessage() self.send_ua_networkMessage()
time.sleep(0.1) time.sleep(0.1)
outs, _ = self.qjs_process.communicate(b'q\n', timeout=15) self.assertEqual(ws.recv_frame().data, MESSAGE_CONTENT.replace(b'\\', b''))
decoded_out = outs.decode() self.assertEqual(
for line in ( ws.recv_frame().data,
'Subscription 0 | MonitoredItem %s' % MONITORED_ITEM_NB, b''.join((
'Received position of drone 0: %f° %f° %fm %fm' % POSITION_ARRAY_VALUES, b'{"drone_dict":{"0":{"latitude":',
'Received speed of drone 0: %f° %fm/s %fm/s' % SPEED_ARRAY_VALUES, b'"%.6f","longitude":"%.6f","altitude":"%.2f",' % POSITION_ARRAY_OUTPUT_VALUES[:-1],
'Received message for drone 0: %s' % TEST_MESSAGE.decode(), b'"yaw":"%.2f","speed":"%.2f","climbRate":"%.2f",' % SPEED_ARRAY_VALUES,
): b'"timestamp":%d}}}' % POSITION_ARRAY_INPUT_VALUES[-1],
self.assertIn(line, decoded_out) ))
)
ws.close()
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<title>JS-Drone GUI</title>
<script src="script.js"></script>
<style>
button {
padding: 0.5%;
font-size: 24px;
cursor: pointer;
border: none;
border-radius: 10px;
box-shadow: 0 4px #999;
}
button:active {
box-shadow: 0 2px #666;
transform: translateY(4px);
}
div > * {margin: 1%}
label {margin: 2%}
table {width: 30%}
th, td{
padding: 1%;
text-align: center;
vertical-align: middle;
}
.connected {color: green}
.container {
display: flex;
align-items: center;
justify-content: center;
}
.disconnected {color: red}
.gray-button {background-color: lightgray}
.gray-button:hover {background-color: gray}
.green-button {background-color: #4caf50}
.green-button:hover {background-color: #3e8e41}
.red-button {background-color: red}
.red-button {background-color: #e42828}
</style>
</head>
<body>
<header class="container">
<label for="web-socket-status">web socket status:</label>
<output class="disconnected" id="web-socket-status">Disconnected</output>
</header>
<div class="container">
<table>
<tr>
<th></th>
{% for i in range(int(nb_drones)) -%}
<th>Drone {{ i }}</th>
{% endfor %}
</tr>
<tr>
<th>Flight state</th>
{% for i in range(int(nb_drones)) -%}
<td class="disconnected" id="flight_state_{{ i }}">Unknown</td>
{% endfor %}
</tr>
<tr>
<th>Latitude (°)</th>
{% for i in range(int(nb_drones)) -%}
<td id="latitude_{{ i }}"></td>
{% endfor %}
</tr>
<tr>
<th>Longitude (°)</th>
{% for i in range(int(nb_drones)) -%}
<td id="longitude_{{ i }}"></td>
{% endfor %}
</tr>
<tr>
<th>Altitude (m)</th>
{% for i in range(int(nb_drones)) -%}
<td id="altitude_{{ i }}"></td>
{% endfor %}
</tr>
<tr>
<th>Yaw (°)</th>
{% for i in range(int(nb_drones)) -%}
<td id="yaw_{{ i }}"></td>
{% endfor %}
</tr>
<tr>
<th>Speed (m/s)</th>
{% for i in range(int(nb_drones)) -%}
<td id="speed_{{ i }}"></td>
{% endfor %}
</tr>
<tr>
<th>Climb rate (m/s)</th>
{% for i in range(int(nb_drones)) -%}
<td id="climb_rate_{{ i }}"></td>
{% endfor %}
</tr>
</table>
</div>
<div class="container">
<button id="flight-btn" class="green-button" type="button">
Start
</button>
<button id="switch-btn" class="gray-button" type="button">
Switch leader
</button>
<button id="quit-btn" class="red-button" type="button">
Quit
</button>
</div>
</body>
</html>
/*jslint nomen: true, indent: 2, maxerr: 3, maxlen: 80 */
(function () {
"use strict";
var ALTITUDE_BASE_ID = "altitude_",
SPEED_BASE_ID = "speed_",
CONNECTED_CLASS_NAME = "connected",
CLIMB_RATE_BASE_ID = "climb_rate_",
DISCONNECTED_CLASS_NAME = "disconnected",
FLIGHT_BTN_ID = "flight-btn",
FLIGHT_STATUS_BASE_ID = "flight_state_",
GREEN_BTN_CLASS_NAME = "green-button",
LATITUDE_BASE_ID = "latitude_",
LONGITUDE_BASE_ID = "longitude_",
QUIT_BTN_ID = "quit-btn",
RED_BTN_CLASS_NAME = "red-button",
SWITCH_BTN_ID = "switch-btn",
WEB_SOCKET_STATUS_OUTPUT_ID = "web-socket-status",
YAW_BASE_ID = "yaw_",
socket;
function updateConnexionClass(element, status) {
element.classList.remove(status ? DISCONNECTED_CLASS_NAME : CONNECTED_CLASS_NAME);
element.classList.add(status ? CONNECTED_CLASS_NAME : DISCONNECTED_CLASS_NAME);
}
function setWebSocketStatus(connected, status) {
var status_output = document.getElementById(WEB_SOCKET_STATUS_OUTPUT_ID);
updateConnexionClass(status_output, connected);
status_output.value = status;
}
function stopFlight(event) {
socket.send("stop");
event.target.removeEventListener('click', stopFlight);
}
function startFlight(event) {
var button = event.target;
socket.send("start");
button.removeEventListener('click', startFlight);
button.innerHTML = "Stop";
button.classList.remove(GREEN_BTN_CLASS_NAME);
button.classList.add(RED_BTN_CLASS_NAME);
button.addEventListener('click', stopFlight);
}
socket = new WebSocket('ws://{{ websocket_url }}');
socket.onopen = function(event) {
setWebSocketStatus(true, "Connected");
};
socket.onmessage = function(event) {
var message = JSON.parse(event.data),
flight_state_cell;
if (message.hasOwnProperty("drone_dict")) {
Object.entries(message["drone_dict"]).forEach(function ([id, drone]) {
document.getElementById(LATITUDE_BASE_ID + id).innerHTML = drone["latitude"];
document.getElementById(LONGITUDE_BASE_ID + id).innerHTML = drone["longitude"];
document.getElementById(ALTITUDE_BASE_ID + id).innerHTML = drone["altitude"];
document.getElementById(YAW_BASE_ID + id).innerHTML = drone["yaw"];
document.getElementById(SPEED_BASE_ID + id).innerHTML = drone["speed"];
document.getElementById(CLIMB_RATE_BASE_ID + id).innerHTML = drone["climbRate"];
});
} else if (message.hasOwnProperty("state") && message.hasOwnProperty("id")) {
flight_state_cell = document.getElementById(FLIGHT_STATUS_BASE_ID + message['id']);
flight_state_cell.innerHTML = message['state'];
updateConnexionClass(flight_state_cell, message['inAir']);
} else {
console.info(message);
}
};
socket.onclose = function(event) {
setWebSocketStatus(false, "Closed");
};
socket.onerror = function(event) {
console.error(event.reason);
};
document.addEventListener("DOMContentLoaded", () => {
document.getElementById(FLIGHT_BTN_ID).addEventListener('click', startFlight);
document.getElementById(SWITCH_BTN_ID).addEventListener('click', event => {
socket.send("switch");
});
document.getElementById(QUIT_BTN_ID).addEventListener('click', event => {
socket.send("quit");
});
});
}());
/*jslint nomen: true, indent: 2, maxerr: 3, maxlen: 80 */
/*global console, getAltitude, getAltitudeRel, getInitialAltitude, getLatitude,
getLongitude, getYaw, execUserScript, initPubsub, landed, loiter, setAirspeed,
setMessage, setTargetCoordinates, std, triggerParachute, Drone, Worker*/
import {
Drone,
triggerParachute,
getAirspeed,
getAltitude,
getAltitudeRel,
getClimbRate,
getInitialAltitude,
getLatitude,
getLongitude,
getYaw,
initPubsub,
landed,
loiter,
setAirspeed,
setMessage,
setTargetCoordinates
} from {{ json_module.dumps(qjs_wrapper) }};
import * as std from "std";
import { Worker } from "os";
(function (console, getAltitude, getAltitudeRel, getInitialAltitude,
getLatitude, getLongitude, getYaw, initPubsub, landed, loiter,
setAirspeed, setMessage, setTargetCoordinates, std, triggerParachute,
Drone, Worker) {
// Every script is evaluated per drone
"use strict";
var CONF_PATH = {{ json_module.dumps(configuration) }},
conf_file = std.open(CONF_PATH, "r"),
configuration = JSON.parse(conf_file.readAsString()),
parent = Worker.parent,
user_me = {
//for debugging purpose
fdopen: std.fdopen,
in: std.in,
//required to fly
triggerParachute: triggerParachute,
drone_dict: {},
exit: function (exit_code) {
parent.postMessage({type: "exited", exit: exit_code});
parent.onmessage = null;
},
getAltitudeAbs: getAltitude,
getCurrentPosition: function () {
return {
x: getLatitude(),
y: getLongitude(),
z: getAltitudeRel()
};
},
getInitialAltitude: getInitialAltitude,
getYaw: getYaw,
getSpeed: getAirspeed,
getClimbRate: getClimbRate,
id: configuration.id,
landed: landed,
loiter: loiter,
sendMsg: function (msg, id) {
if (id === undefined) { id = -1; }
setMessage(JSON.stringify({ content: msg, dest_id: id }));
},
setAirspeed: setAirspeed,
setTargetCoordinates: setTargetCoordinates
};
conf_file.close();
function loadUserScript(path) {
var script_content = std.loadFile(path);
if (script_content === null) {
console.log("Failed to load user script " + path);
std.exit(1);
}
try {
std.evalScript(
"function execUserScript(from, me) {" + script_content + "};"
);
} catch (e) {
console.log("Failed to evaluate user script", e);
std.exit(1);
}
execUserScript(null, user_me);
// Call the drone onStart function
if (user_me.hasOwnProperty("onStart")) {
user_me.onStart();
}
}
function handleMainMessage(evt) {
var type = evt.data.type, message, drone_id;
if (type === "initPubsub") {
initPubsub(configuration.numberOfPeers);
for (drone_id = 0; drone_id < configuration.numberOfPeers; drone_id++) {
user_me.drone_dict[drone_id] = new Drone(drone_id);
user_me.drone_dict[drone_id].init(drone_id);
}
parent.postMessage({type: "initialized"});
} else if (type === "load") {
loadUserScript(evt.data.path);
parent.postMessage({type: "loaded"});
} else if (type === "update") {
Object.entries(user_me.drone_dict).forEach(function ([id, drone]) {
message = drone.message;
if (user_me.id !== Number(id) && message.length > 0) {
message = JSON.parse(message);
if (user_me.hasOwnProperty("onGetMsg") &&
[-1, user_me.id].includes(message.dest_id)) {
user_me.onGetMsg(message.content);
}
}
});
// Call the drone onStart function
if (user_me.hasOwnProperty("onUpdate")) {
user_me.onUpdate(evt.data.timestamp);
}
parent.postMessage({type: "updated"});
} else {
throw new Error("Unsupported message type", type);
}
}
parent.onmessage = function (evt) {
try {
handleMainMessage(evt);
} catch (error) {
// Catch all potential bug to exit the main process
// if it occurs
console.log(error);
std.exit(1);
}
};
}(console, getAltitude, getAltitudeRel, getInitialAltitude, getLatitude,
getLongitude, getYaw, initPubsub, landed, loiter, setAirspeed, setMessage,
setTargetCoordinates, std, triggerParachute, Drone, Worker));
...@@ -194,6 +194,7 @@ Flask = 3.0.0:whl ...@@ -194,6 +194,7 @@ Flask = 3.0.0:whl
frozenlist = 1.4.0:whl frozenlist = 1.4.0:whl
funcsigs = 1.0.2 funcsigs = 1.0.2
functools32 = 3.2.3.post2 functools32 = 3.2.3.post2
future = 0.18.3
gevent = 23.9.1 gevent = 23.9.1
geventmp = 0.0.1 geventmp = 0.0.1
gitdb = 4.0.10 gitdb = 4.0.10
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment