Commit a14f1408 authored by Léo-Paul Géneau's avatar Léo-Paul Géneau 👾

software/js-drone: add software-type drone

Now the default software-type request a list of drone software-type
parent 396094ed
...@@ -2,26 +2,29 @@ ...@@ -2,26 +2,29 @@
## Presentation ## ## Presentation ##
* Deploy `user.js` script on a drone to fly it * Deploy `user.js` flight script on a drone swarm
* Compile all required libraries to run flight scripts * Compile all required libraries to run the flight script
## Parameters ## ## Parameters ##
* autopilot-ip: IPv4 address to identify the autpilot from the companion board * autopilot-ip: IPv4 address to identify the autopilot from the companion board
* id: User chosen ID for the drone (must be unique in a swarm, will be used as an identifier in multicast communications) * drone-guid-list: List of computer id on which flight script must be deployed
* is-a-simulation: Must be set to 'true' to automatically take off during simulation * is-a-simulation: Must be set to 'true' to automatically take off during simulation
* multicast-ipv6: IPv6 of the multicast group of the swarm * multicast-ip: IPv6 of the multicast group of the swarm
* net-if: Network interface used for multicast traffic * net-if: Network interface used for multicast traffic
* drone-id-list: List of the drone IDs of the swarm (recommended to add the current drone ID) * flight-script: URL of user's script to execute to fly drone swarm
* flight-script: User script to execute to fly drone swarm * subscriber-guid-list: List of computer id on which subscription script must be deployed
## How it works ## ## How it works ##
Run `quickjs binary location` `scripts location`/main.js `scripts location`/user.js For each computer listed in `drone-guid-list` and `subscriber-guid-list` a drone SR will be instanciated.
Each instance will return a `instance-path`. Under this path one will find `quickjs binary` in `bin` folder
and `scripts` in `etc` folder.
Run `quickjs binary location` `scripts location`/main.js `scripts location`/user.js .
...@@ -14,16 +14,24 @@ ...@@ -14,16 +14,24 @@
# not need these here). # not need these here).
[instance-profile] [instance-profile]
filename = instance.cfg filename = instance.cfg
md5sum = 7d4969239eb9d46bb44d57fc32b68c44 md5sum = 360b58007c25727b7bd8a9154d5cafd4
[instance-default]
filename = instance-default.cfg
md5sum = b26633b118cddd7c7b8dfd61b360999c
[instance-drone]
filename = instance-drone.cfg
md5sum = 1ff50063f5a54712a0bc0ff38fa74630
[main] [main]
filename = main.js filename = main.js
md5sum = 4b1b27ea3e06b8d40cbc33f0ec617601 md5sum = c381d2a6c4008a9ef69dd176d3a06028
[pubsub] [pubsub]
filename = pubsub.js filename = pubsub.js
md5sum = 4a0c63f9e088fa525a3699484d193c4d md5sum = c732be66f8ec97bd16cd34d06a0c0a0b
[worker] [worker]
filename = worker.js filename = worker.js
md5sum = 5ed534e9ca56b9c0ee321b96b5d7c458 md5sum = d919ce35d42561bc38a06273781cb702
{% set autopilot_ip = slapparameter_dict.get('autopilotIp', '192.168.27.1') -%}
{% 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 subscriber_id_list = [] -%}
{% set part_list = ['publish-connection-information'] -%}
{% for id, guid in enumerate(guid_list) -%}
{% set request_drone_section_title = 'request-drone' ~ id -%}
{% do part_list.append(request_drone_section_title) %}
[{{ request_drone_section_title }}]
<= slap-connection
recipe = slapos.cookbook:request.serialised
name = Drone{{ id }}
software-url = $${:software-release-url}
software-type = drone
return = instance-path
sla-computer_guid = {{ guid }}
config-autopilotIp = {{ autopilot_ip }}
config-numberOfPeers = {{ dumps(nb_peer) }}
config-id = {{ dumps(id) }}
config-isASimulation = {{ dumps(is_a_simulation) }}
{% if guid in drone_guid_list -%}
{% do drone_id_list.append(id) %}
config-isADrone = {{ dumps(True) }}
config-flightScript = {{ flight_script }}
{% else -%}
{% do subscriber_id_list.append(id) %}
config-isADrone = {{ dumps(False) }}
config-flightScript = https://lab.nexedi.com/nexedi/flight-scripts/raw/master/subscribe.js
{% endif -%}
config-multicastIp = {{ multicast_ip }}
config-netIf = {{ net_if }}
{% endfor %}
[publish-connection-information]
recipe = slapos.cookbook:publish.serialised
drone-id-list = {{ dumps(drone_id_list) }}
subscriber-id-list = {{ dumps(subscriber_id_list) }}
[buildout]
parts =
{%- for part in part_list %}
{{ part }}
{%- endfor -%}
{
"$schema": "http://json-schema.org/draft-06/schema",
"type": "object",
"description": "Parameters to instantiate JS drone",
"additionalProperties": false,
"properties": {
"autopilotIp": {
"title": "IP address of the drone's autopilot",
"description": "IP used to create a connection with the autopilot.",
"type": "string"
},
"numberOfPeers": {
"title": "Number of Peers",
"description": "Number of drones and subscribers in the swarm",
"type": "integer"
},
"id": {
"title": "drone ID",
"description": "Drone unique identifier",
"type": "integer"
},
"isADrone": {
"title": "Set the requested instance as a drone",
"description": "The option used to determine if the instance is a drone. This affects the context of the user script (e.g. if it should be linked to an autopilot or publish its GPS coordinates)",
"type": "boolean"
},
"isASimulation": {
"title": "Set the flight as a simulation",
"description": "The option used to determine if the flight is real or if it is a simulation. This affects the context of the flight (e.g. if the take off is manual or automatic).",
"type": "boolean"
},
"multicastIp": {
"title": "IP of the multicast group",
"description": "IP address used to communicate with the other drones.",
"type": "string"
},
"netIf": {
"title": "Network interface",
"description": "Interface used for multicast traffic.",
"type": "string"
},
"flightScript": {
"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 all drones can fetch the script.",
"type": "string"
}
}
}
{
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Values returned by drone instantiation",
"additionalProperties": false,
"properties": {
"instance-path": {
"description": "Path of the directory where the quickjs binary and the flight scripts are located",
"type": "string"
}
},
"type": "object"
}
[buildout]
parts =
main
symlink-quickjs-binary
publish-connection-information
[directory]
recipe = slapos.cookbook:mkdirectory
home = $${buildout:directory}
bin = $${:home}/bin
etc = $${:home}/etc
var = $${:home}/var
log = $${:var}/log
[js-dynamic-template]
recipe = slapos.recipe.template:jinja2
rendered = $${directory:etc}/$${:_buildout_section_name_}.js
template = ${buildout:directory}/$${:_buildout_section_name_}.js
extra-context =
context =
import json_module json
raw qjs_wrapper ${qjs-wrapper:location}/lib/libqjswrapper.so
raw configuration {{ configuration }}
$${:extra-context}
[main]
<= js-dynamic-template
extra-context =
key log_dir directory:log
key pubsub_script pubsub:rendered
key worker_script worker:rendered
[pubsub]
<= js-dynamic-template
[worker]
<= js-dynamic-template
[symlink-quickjs-binary]
recipe = slapos.recipe.build
binary-path = ${quickjs:location}/bin/qjs
target = $${directory:bin}/qjs
init =
import os
if not os.path.exists(options['target']):
os.symlink(options['binary-path'], options['target'])
[publish-connection-information]
recipe = slapos.cookbook:publish.serialised
instance-path = $${directory:home}
...@@ -4,47 +4,47 @@ ...@@ -4,47 +4,47 @@
"description": "Parameters to instantiate JS drone", "description": "Parameters to instantiate JS drone",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"autopilot-ip": { "autopilotIp": {
"title": "IP address of the drone's autopilot", "title": "IP address of the drone's autopilot",
"description": "IP used to create a connection with the autopilot.", "description": "IP used to create a connection with the autopilot.",
"type": "string", "type": "string",
"default": "192.168.27.1" "default": "192.168.27.1"
}, },
"id": { "droneGuidList": {
"title": "Drone ID", "title": "List of drones computer ID",
"description": "Unique identifier of the drone.", "description": "List of computer ID of drones in the swarm",
"type": "integer", "type": "array",
"default": 1 "default": []
}, },
"is-a-simulation": { "isASimulation": {
"title": "Set the flight as a simulation", "title": "Set the flight as a simulation",
"description": "The option used to determine if the flight is real or if it is a simulation. This affects the context of the flight (e.g. if the take off is manual or automatic).", "description": "The option used to determine if the flight is real or if it is a simulation. This affects the context of the flight (e.g. if the take off is manual or automatic).",
"type": "boolean", "type": "boolean",
"default": false "default": false
}, },
"multicast-ipv6": { "multicastIpv6": {
"title": "IP of the multicast group", "title": "IP of the multicast group",
"description": "IP address used to communicate with the other drones.", "description": "IP address used to communicate with the other drones.",
"type": "string", "type": "string",
"default": "ff15::1111" "default": "ff15::1111"
}, },
"net-if": { "netIf": {
"title": "Network interface", "title": "Network interface",
"description": "Interface used for multicast traffic.", "description": "Interface used for multicast traffic.",
"type": "string", "type": "string",
"default": "eth0" "default": "eth0"
}, },
"drone-id-list": { "flightScript": {
"title": "List of drones IDs", "title": "Script's URL of the flight",
"description": "List of identifiers of drones.", "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",
"default": "https://lab.nexedi.com/nexedi/flight-scripts/raw/master/default.js"
},
"subscriberGuidList": {
"title": "List of subscribers computer ID",
"description": "List of computer ID of swarms subscribers",
"type": "array", "type": "array",
"default": [] "default": []
},
"flight-script": {
"title": "Script of the flight",
"description": "Script which will be executed for the flight",
"type": "string",
"textarea": true
} }
} }
} }
...@@ -2,6 +2,15 @@ ...@@ -2,6 +2,15 @@
"$schema": "http://json-schema.org/draft-04/schema#", "$schema": "http://json-schema.org/draft-04/schema#",
"description": "Values returned by drone swarm (default) instantiation", "description": "Values returned by drone swarm (default) instantiation",
"additionalProperties": false, "additionalProperties": false,
"properties": {}, "properties": {
"drone-id-list": {
"description": "List of drones IDs",
"type": "array"
},
"subscriber-id-list": {
"description": "List of subscribers IDs",
"type": "array"
}
},
"type": "object" "type": "object"
} }
[buildout] [buildout]
parts = parts =
main switch-softwaretype
user
eggs-directory = ${buildout:eggs-directory} eggs-directory = ${buildout:eggs-directory}
develop-eggs-directory = ${buildout:develop-eggs-directory} develop-eggs-directory = ${buildout:develop-eggs-directory}
offline = true offline = true
[directory] [switch-softwaretype]
recipe = slapos.cookbook:mkdirectory recipe = slapos.cookbook:switch-softwaretype
home = $${buildout:directory} default = instance-default:output
etc = $${:home}/etc drone = instance-drone:output
var = $${:home}/var RootSoftwareInstance = $${:default}
log = $${:var}/log
[slap-configuration] [slap-configuration]
recipe = slapos.cookbook:slapconfiguration recipe = slapos.cookbook:slapconfiguration.serialised
computer = $${slap_connection:computer_id} computer = $${slap_connection:computer_id}
partition = $${slap_connection:partition_id} partition = $${slap_connection:partition_id}
url = $${slap_connection:server_url} url = $${slap_connection:server_url}
key = $${slap_connection:key_file} key = $${slap_connection:key_file}
cert = $${slap_connection:cert_file} cert = $${slap_connection:cert_file}
[drone] [dynamic-template-base]
recipe = slapos.recipe.build recipe = slapos.recipe.template:jinja2
slapparameter-dict = $${slap-configuration:configuration} url = ${buildout:directory}/$${:_buildout_section_name_}.cfg
init = output = $${buildout:directory}/$${:_buildout_section_name_}
options['autopilot-ip'] = options['slapparameter-dict'].get('autopilot-ip', '192.168.27.1')
options['id'] = options['slapparameter-dict'].get('id', 1)
options['is-a-simulation'] = options['slapparameter-dict'].get('is-a-simulation', False)
options['multicast-ipv6'] = options['slapparameter-dict'].get('multicast-ip', 'ff15::1111')
options['net-if'] = options['slapparameter-dict'].get('net-if', 'eth0')
options['drone-id-list'] = options['slapparameter-dict'].get('drone-id-list', [])
options['is-a-drone'] = 'flight-script' in options['slapparameter-dict']
subscription_script = '''
me.onStart = function() {
me.f = me.fdopen(me.in, "r");
console.log("Use q to quit");
};
me.onUpdate= function(timestamp) { [instance-default]
while(me.f.getline() != "q") { <= dynamic-template-base
continue; extensions = jinja2.ext.do
} context =
try { key slapparameter_dict slap-configuration:configuration
me.f.close();;
} catch (error) {
console.error(error);
}
me.exit(0);
};
'''
options['flight-script'] = options['slapparameter-dict'].get('flight-script', subscription_script) [instance-drone]
<= dynamic-template-base
context =
key configuration drone-configuration:output
key user-script user:destination
[js-dynamic-template] [drone-configuration]
recipe = slapos.recipe.template:jinja2 recipe = slapos.recipe.template:jinja2
rendered = $${directory:etc}/$${:_buildout_section_name_}.js output = $${directory:etc}/configuration.json
template = ${buildout:directory}/$${:_buildout_section_name_}.js
extra-context =
context = context =
raw qjs_wrapper ${qjs-wrapper:location}/lib/libqjswrapper.so import json_module json
$${:extra-context} key slapparameter_dict slap-configuration:configuration
inline = {{ json_module.dumps(slapparameter_dict) }}
[main]
<= js-dynamic-template
extra-context =
key autopilot_ip drone:autopilot-ip
key id drone:id
key is_a_simulation drone:is-a-simulation
key is_a_drone drone:is-a-drone
key log_dir directory:log
key pubsub_script pubsub:rendered
key worker_script worker:rendered
[pubsub]
<= js-dynamic-template
extra-context =
key ipv6 drone:multicast-ipv6
key net_if drone:net-if
[user] [user]
recipe = slapos.recipe.template:jinja2 recipe = slapos.recipe.build:download
output = $${directory:etc}/user.js url = $${slap-configuration:configuration.flightScript}
context = destination = $${directory:etc}/user.js
key script drone:flight-script offline = false
inline = {{ script }}
[worker] [directory]
<= js-dynamic-template recipe = slapos.cookbook:mkdirectory
extra-context = home = $${buildout:directory}
key drone_id_list drone:drone-id-list etc = $${:home}/etc
key id drone:id
key is_a_drone drone:is-a-drone
...@@ -5,17 +5,20 @@ import { ...@@ -5,17 +5,20 @@ import {
stop, stop,
stopPubsub, stopPubsub,
takeOffAndWait takeOffAndWait
} from "{{ qjs_wrapper }}"; } from {{ json_module.dumps(qjs_wrapper) }};
import { setTimeout, Worker } from "os"; import { setTimeout, Worker } from "os";
import { exit } from "std"; import { open, exit } from "std";
(function (console, setTimeout, Worker) { (function (console, setTimeout, Worker) {
"use strict"; "use strict";
const IP = "{{ autopilot_ip }}", const CONF_PATH = {{ json_module.dumps(configuration) }};
URL = "udp://" + IP + ":7909",
LOG_FILE = "{{ log_dir }}/mavsdk-log", var conf_file = open(CONF_PATH, "r");
IS_A_DRONE = {{ 'true' if is_a_drone else 'false' }}, const configuration = JSON.parse(conf_file.readAsString());
SIMULATION = {{ 'true' if is_a_simulation else 'false' }}; conf_file.close();
const URL = "udp://" + configuration.autopilotIp + ":7909",
LOG_FILE = "{{ log_dir }}/mavsdk-log";
// 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
...@@ -51,7 +54,7 @@ import { exit } from "std"; ...@@ -51,7 +54,7 @@ import { exit } from "std";
exit(exit_code); exit(exit_code);
} }
if (IS_A_DRONE) { if (configuration.isADrone) {
console.log("Connecting to aupilot\n"); console.log("Connecting to aupilot\n");
connect(); connect();
} }
...@@ -71,7 +74,7 @@ import { exit } from "std"; ...@@ -71,7 +74,7 @@ import { exit } from "std";
} }
function load() { function load() {
if (IS_A_DRONE && SIMULATION) { if (configuration.isADrone && configuration.isASimulation) {
takeOff(); takeOff();
} }
...@@ -118,9 +121,9 @@ import { exit } from "std"; ...@@ -118,9 +121,9 @@ import { exit } from "std";
if (type === 'initialized') { if (type === 'initialized') {
pubsubWorker.postMessage({ pubsubWorker.postMessage({
action: "run", action: "run",
id: {{ id }}, id: configuration.id,
interval: FPS, interval: FPS,
publish: IS_A_DRONE publish: configuration.isADrone
}); });
load(); load();
} else if (type === 'loaded') { } else if (type === 'loaded') {
...@@ -132,10 +135,10 @@ import { exit } from "std"; ...@@ -132,10 +135,10 @@ import { exit } from "std";
can_update = true; can_update = true;
} else if (type === 'exited') { } else if (type === 'exited') {
worker.onmessage = null; worker.onmessage = null;
quit(IS_A_DRONE, e.data.exit); quit(configuration.isADrone, e.data.exit);
} else { } else {
console.log('Unsupported message type', type); console.log('Unsupported message type', type);
quit(IS_A_DRONE, 1); quit(configuration.isADrone, 1);
} }
}; };
}(console, setTimeout, Worker)); }(console, setTimeout, Worker));
import {runPubsub} from "{{ qjs_wrapper }}"; import {runPubsub} from {{ json_module.dumps(qjs_wrapper) }};
import {Worker} from "os"; import {Worker} from "os";
import {open} from "std";
const PORT = "4840"; const CONF_PATH = {{ json_module.dumps(configuration) }},
const IPV6 = "{{ ipv6 }}"; PORT = "4840";
let parent = Worker.parent; let parent = Worker.parent;
var conf_file = open(CONF_PATH, "r");
const configuration = JSON.parse(conf_file.readAsString());
conf_file.close();
function handle_msg(e) { function handle_msg(e) {
switch(e.data.action) { switch(e.data.action) {
case "run": case "run":
runPubsub(IPV6, PORT, "{{ net_if }}", e.data.id, e.data.interval, e.data.publish); runPubsub(configuration.multicastIp, PORT, configuration.netIf, e.data.id, e.data.interval, e.data.publish);
parent.postMessage({running: false}); parent.postMessage({running: false});
parent.onmessage = null; parent.onmessage = null;
break; break;
......
...@@ -6,21 +6,34 @@ extends = ...@@ -6,21 +6,34 @@ extends =
parts = parts =
instance-profile instance-profile
instance-default
instance-drone
main main
pubsub pubsub
worker worker
slapos-cookbook slapos-cookbook
[download-file-base]
recipe = slapos.recipe.build:download
url = ${:_profile_base_location_}/${:filename}
destination = ${buildout:directory}/${:filename}
[instance-profile] [instance-profile]
recipe = slapos.recipe.template recipe = slapos.recipe.template
url = ${:_profile_base_location_}/${:filename} url = ${:_profile_base_location_}/${:filename}
output = ${buildout:directory}/template.cfg output = ${buildout:directory}/template.cfg
[jinja-template-base]
recipe = slapos.recipe.template
url = ${:_profile_base_location_}/${:_buildout_section_name_}.cfg
output = ${buildout:directory}/${:_buildout_section_name_}.cfg
[instance-default]
<= jinja-template-base
[instance-drone]
<= jinja-template-base
[download-file-base]
recipe = slapos.recipe.build:download
url = ${:_profile_base_location_}/${:filename}
destination = ${buildout:directory}/${:filename}
[main] [main]
<= download-file-base <= download-file-base
......
{ {
"name": "JS Drone", "name": "JS Drone",
"description": "JS Drone", "description": "JS Drone",
"serialisation": "xml", "serialisation": "json-in-xml",
"software-type": { "software-type": {
"default": { "default": {
"title": "Default", "title": "Default",
"software-type": "default", "software-type": "default",
"description": "Default", "description": "Drone Swarm",
"request": "instance-input-schema.json", "request": "instance-input-schema.json",
"response": "instance-output-schema.json", "response": "instance-output-schema.json",
"index": 0 "index": 0
},
"drone": {
"title": "Drone",
"software-type": "drone",
"description": "Drone Instance",
"request": "instance-drone-input-schema.json",
"response": "instance-drone-output-schema.json",
"index": 1
} }
} }
} }
...@@ -17,16 +17,19 @@ import { ...@@ -17,16 +17,19 @@ import {
setManualControlInput, setManualControlInput,
setMessage, setMessage,
setTargetCoordinates setTargetCoordinates
} from "{{ qjs_wrapper }}"; } from {{ json_module.dumps(qjs_wrapper) }};
import * as std from "std"; import * as std from "std";
import { Worker } from "os"; import { Worker } from "os";
(function (console, Worker) { (function (console, Worker) {
// Every script is evaluated per drone // Every script is evaluated per drone
"use strict"; "use strict";
const drone_dict = {}, const CONF_PATH = {{ json_module.dumps(configuration) }},
drone_id_list = [{{ drone_id_list }}], drone_dict = {};
IS_A_DRONE = {{ 'true' if is_a_drone else 'false' }};
var conf_file = std.open(CONF_PATH, "r");
const configuration = JSON.parse(conf_file.readAsString());
conf_file.close();
let parent = Worker.parent, let parent = Worker.parent,
user_me = { user_me = {
...@@ -50,7 +53,7 @@ import { Worker } from "os"; ...@@ -50,7 +53,7 @@ import { Worker } from "os";
}, },
getInitialAltitude: getInitialAltitude, getInitialAltitude: getInitialAltitude,
getYaw: getYaw, getYaw: getYaw,
id: {{ id }}, id: configuration.id,
landed: landed, landed: landed,
loiter: loiter, loiter: loiter,
sendMsg: function(msg, id = -1) { sendMsg: function(msg, id = -1) {
...@@ -86,16 +89,13 @@ import { Worker } from "os"; ...@@ -86,16 +89,13 @@ import { Worker } from "os";
} }
function handleMainMessage(evt) { function handleMainMessage(evt) {
let type = evt.data.type, var type = evt.data.type, message, drone_id;
message,
drone_id;
if (type === "initPubsub") { if (type === "initPubsub") {
initPubsub(drone_id_list.length); initPubsub(configuration.numberOfPeers);
for (let i = 0; i < drone_id_list.length; i++) { for (drone_id = 0; drone_id < configuration.numberOfPeers; drone_id++) {
drone_id = drone_id_list[i];
user_me.drone_dict[drone_id] = new Drone(drone_id); user_me.drone_dict[drone_id] = new Drone(drone_id);
user_me.drone_dict[drone_id].init(i); user_me.drone_dict[drone_id].init(drone_id);
} }
parent.postMessage({type: "initialized"}); parent.postMessage({type: "initialized"});
} else if (type === "load") { } else if (type === "load") {
...@@ -117,7 +117,7 @@ import { Worker } from "os"; ...@@ -117,7 +117,7 @@ import { Worker } from "os";
} }
// Call the drone onStart function // Call the drone onStart function
if (user_me.hasOwnProperty("onUpdate")) { if (user_me.hasOwnProperty("onUpdate")) {
if (IS_A_DRONE && isInManualMode()) { if (configuration.isADrone && isInManualMode()) {
setManualControlInput(); setManualControlInput();
} }
user_me.onUpdate(evt.data.timestamp); user_me.onUpdate(evt.data.timestamp);
......
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