diff --git a/.gitignore b/.gitignore index 68f96a5..6e6131c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +webapp/node_modules/ *venv/ .DS_Store *.iml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cccab4e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,23 @@ +# Contributing to Jitsi-temi + +The following is a set of guidelines for contributing to this repository on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. + +#### Table Of Contents + +[Styleguides](#styleguides) + * [Git Commit Messages](#git-commit-messages) + * [JavaScript Styleguide](#javascript-styleguide) + +## Styleguides + +### Git Commit Messages + +* Use the present tense ("Add feature" not "Added feature") +* Use the imperative form ("Move cursor to..." not "Moves cursor to...") +* Limit the first line to 72 characters or less +* Reference issues and pull requests liberally after the first line + + +### JavaScript Styleguide + +Follow the [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..092ba53 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 hapi-robo s.t. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 42be924..d2476db 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,28 @@ # jitsi-temi -Jitsi for temi +A free and open-source alternative to temi's default video-conferencing application. -## Usage -Start MQTT broker: + +## TL;DR +Install APK onto temi. +``` +adb connect +adb install connect.apk +``` + +Start the MQTT broker and web-server: ``` +docker-compose build docker-compose up ``` -Start webapp. +Start the `Connect` app on temi. Type in the IP-address of the MQTT broker and press `Connect`. + +In your web-browser, type the IP-address of the web-server. + +If everything is working correctly, you should see your temi's serial number appear. Click it to start tele-operations. + -Run Android App on temi. +## Attributions +This project would not have been made possible without the help of other open-source projects: +* [Jitsi](https://jitsi.org/) +* [Eclipe Paho](https://www.eclipse.org/paho/) diff --git a/android/app/build.gradle b/android/app/build.gradle index 506e510..a47ee7b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,7 +7,7 @@ android { minSdkVersion 23 targetSdkVersion 29 versionCode 1 - versionName "1.0" + versionName "0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f0cd5a0..885f0cf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/AppTheme"> + android:theme="@style/SkillTheme"> @@ -25,6 +25,10 @@ + + \ No newline at end of file diff --git a/android/app/src/main/java/com/hapirobo/connect/MainActivity.java b/android/app/src/main/java/com/hapirobo/connect/MainActivity.java index 9c4f0dc..340b18f 100644 --- a/android/app/src/main/java/com/hapirobo/connect/MainActivity.java +++ b/android/app/src/main/java/com/hapirobo/connect/MainActivity.java @@ -2,8 +2,10 @@ import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; +import android.os.Handler; import android.util.Log; import android.view.View; +import android.widget.EditText; import android.widget.Toast; import com.robotemi.sdk.BatteryData; @@ -39,140 +41,94 @@ public class MainActivity extends AppCompatActivity implements OnRobotReadyListener, OnBatteryStatusChangedListener, OnGoToLocationStatusChangedListener { + private static final String TAG = "DEBUG"; + + private static Handler sHandler = new Handler(); + private static Robot sRobot; + private static String sSerialNumber; + private MqttAndroidClient mMqttClient; + private Runnable periodicTask = new Runnable() { + // periodically publishes robot status to the MQTT broker. + @Override + public void run() { + sHandler.postDelayed(this, 3000); + + try { + MainActivity.this.robotPublishStatus(); + } catch (JSONException e) { + e.printStackTrace(); + } + } + }; - private static final String TAG = "MQTT"; - - private Robot robot; - private String serialNumber; - private MqttAndroidClient mqttClient; - + /** + * Initializes robot instance and default Jitsi-meet conference options. + * @param savedInstanceState + */ @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); - // initialize MQTT - initMQTT("tcp://192.168.0.118:1883", "temi-android"); - // initialize robot - robot = Robot.getInstance(); + sRobot = Robot.getInstance(); // initialize default options for Jitsi Meet conferences. - URL serverURL; + URL serverUrl; try { - serverURL = new URL("https://meet.jit.si"); + serverUrl = new URL("https://meet.jit.si"); } catch (MalformedURLException e) { e.printStackTrace(); throw new RuntimeException("Invalid server URL!"); } JitsiMeetConferenceOptions defaultOptions = new JitsiMeetConferenceOptions.Builder() - .setServerURL(serverURL) + .setServerURL(serverUrl) .setWelcomePageEnabled(false) .build(); JitsiMeet.setDefaultConferenceOptions(defaultOptions); } + /** + * Adds robot event listeners. + */ @Override protected void onStart() { super.onStart(); - robot.addOnRobotReadyListener(this); - robot.addOnBatteryStatusChangedListener(this); - robot.addOnGoToLocationStatusChangedListener(this); + sRobot.addOnRobotReadyListener(this); + sRobot.addOnBatteryStatusChangedListener(this); + sRobot.addOnGoToLocationStatusChangedListener(this); } + /** + * Removes robot event listeners. + */ @Override protected void onStop() { super.onStop(); - - robot.removeOnRobotReadyListener(this); - robot.removeOnBatteryStatusChangedListener(this); - robot.removeOnGoToLocationStatusChangedListener(this); - } - - public void onButtonClick(View v) { - String text = "temi-" + serialNumber; - - if (text.length() > 0) { - // Build options object for joining the conference. The SDK will merge the default - // one we set earlier and this one when joining. - JitsiMeetConferenceOptions options - = new JitsiMeetConferenceOptions.Builder() - .setRoom(text) - .build(); - // Launch the new activity with the given options. The launch() method takes care - // of creating the required Intent and passing the options. - JitsiMeetActivity.launch(this, options); - } - } - - private void initMQTT(String hostURI, String clientID) { - mqttClient = new MqttAndroidClient(getApplicationContext(), hostURI, clientID); - mqttClient.setCallback(new MqttCallback() { - @Override - public void connectionLost(Throwable cause) { - Log.v(TAG, "Connection lost"); - // this method is called when connection to server is lost - } - - @Override - public void deliveryComplete(IMqttDeliveryToken token) { - // called when delivery for a message has been completed, and all acknowledgements have been received - Log.v(TAG, "Delivery complete"); - } - - @Override - public void messageArrived(String topic, MqttMessage message) throws JSONException { - // this method is called when a message arrives from the server - Log.v(TAG, topic); - Log.v(TAG, message.toString()); - JSONObject payload = new JSONObject(message.toString()); - parseMessage(topic, payload); - } - }); - - MqttConnectOptions mqttConnectOptions = new MqttConnectOptions(); - mqttConnectOptions.setAutomaticReconnect(true); - mqttConnectOptions.setCleanSession(true); - mqttConnectOptions.setConnectionTimeout(10); - - try { - mqttClient.connect(mqttConnectOptions, null, new IMqttActionListener() { - @Override - public void onSuccess(IMqttToken asyncActionToken) { - Toast.makeText(MainActivity.this, "Successfully Connected", Toast.LENGTH_SHORT).show(); - try { - mqttClient.subscribe("temi/+/command/#", 0); - } catch (MqttException e) { - e.printStackTrace(); - } - - try { - robotPublishStatus(); - } catch (JSONException e) { - e.printStackTrace(); - } - } - - @Override - public void onFailure(IMqttToken asyncActionToken, Throwable exception) { - Toast.makeText(MainActivity.this, "Failed to Connect", Toast.LENGTH_SHORT).show(); - } - }); - } catch (MqttException e) { - e.printStackTrace(); - } + sRobot.removeOnRobotReadyListener(this); + sRobot.removeOnBatteryStatusChangedListener(this); + sRobot.removeOnGoToLocationStatusChangedListener(this); } + /** + * Configures robot after it is ready. + * @param isReady True if robot initialized correctly; False otherwise + */ @Override public void onRobotReady(boolean isReady) { if (isReady) { - serialNumber = robot.getSerialNumber(); - robot.hideTopBar(); - Log.v(TAG, "[ROBOT][READY]"); + sSerialNumber = sRobot.getSerialNumber(); + sRobot.hideTopBar(); // hides temi's top menu bar + sRobot.toggleNavigationBillboard(true); // hides navigation billboard + Log.i(TAG, "[ROBOT][READY]"); } } + /** + * Handles battery update events. + * @param batteryData Object containing battery state + */ @Override public void onBatteryStatusChanged(@Nullable BatteryData batteryData) { JSONObject payload = new JSONObject(); @@ -191,13 +147,21 @@ public void onBatteryStatusChanged(@Nullable BatteryData batteryData) { try { MqttMessage message = new MqttMessage(payload.toString().getBytes(StandardCharsets.UTF_8)); - mqttClient.publish("temi/" + serialNumber + "/status/utils/battery", message); - Log.v(TAG, "[STATUS][BATTERY] " + batteryData.getBatteryPercentage() + "% | " + batteryData.isCharging()); + if (mMqttClient != null) { + mMqttClient.publish("temi/" + sSerialNumber + "/status/utils/battery", message); + } } catch (MqttException e) { e.printStackTrace(); } } + /** + * Handles go-to event updates. + * @param location Go-to location name + * @param status Current status + * @param descriptionId Description-identifier of the event + * @param description Verbose description of the event + */ @Override public void onGoToLocationStatusChanged(@NotNull String location, @NotNull String status, int descriptionId, @NotNull String description) { JSONObject payload = new JSONObject(); @@ -228,136 +192,244 @@ public void onGoToLocationStatusChanged(@NotNull String location, @NotNull Strin try { MqttMessage message = new MqttMessage(payload.toString().getBytes(StandardCharsets.UTF_8)); - mqttClient.publish("temi/" + serialNumber + "/status/locations/goto", message); - Log.v(TAG, "[STATUS][GOTO] Location: " + location + " | Status: " + status); + mMqttClient.publish("temi/" + sSerialNumber + "/status/locations/goto", message); } catch (MqttException e) { e.printStackTrace(); } } + /** + * Connects to MQTT broker and launches Jitsi-meet conference room. + * @param v View context + */ + public void onButtonClick(View v) { + EditText hostNameView = findViewById(R.id.edit_text_host_name); + String hostURI = "tcp://" + hostNameView.getText().toString().trim() + ":1883"; + + // TODO if already connected, disconnect from broker and reconnect + + // initialize MQTT + initMqtt(hostURI, "temi-" + sSerialNumber); + } + + /** + * Publish robot status information. + * @throws JSONException + */ + public void robotPublishStatus() throws JSONException { + JSONObject payload = new JSONObject(); + JSONArray waypointArray = new JSONArray(); + + List waypointList = sRobot.getLocations(); + + // collect all waypoints + for (String waypoint : waypointList) { + waypointArray.put(waypoint); + } + + // generate payload + payload.put("waypoint_list", waypointArray); + payload.put("battery_percentage", Objects.requireNonNull(sRobot.getBatteryData()).getBatteryPercentage()); + + try { + MqttMessage message = new MqttMessage(payload.toString().getBytes(StandardCharsets.UTF_8)); + mMqttClient.publish("temi/" + sSerialNumber + "/status/info", message); + } catch (MqttException e) { + e.printStackTrace(); + } + } + + /** + * Initializes MQTT client. + * @param hostUri Host name / URI + * @param clientId Identifier used to uniquely identify this client + */ + private void initMqtt(String hostUri, String clientId) { + mMqttClient = new MqttAndroidClient(getApplicationContext(), hostUri, clientId); + mMqttClient.setCallback(new MqttCallback() { + @Override + public void connectionLost(Throwable cause) { + Toast.makeText(MainActivity.this, "Connection Lost", Toast.LENGTH_SHORT).show(); + Log.i(TAG, "Connection Lost"); + // this method is called when connection to server is lost + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + // called when delivery for a message has been completed, and all acknowledgements have been received + } + + @Override + public void messageArrived(String topic, MqttMessage message) throws JSONException { + // this method is called when a message arrives from the server + Log.i(TAG, topic); + Log.i(TAG, message.toString()); + JSONObject payload = new JSONObject(message.toString()); + parseMessage(topic, payload); + } + }); + + MqttConnectOptions mqttConnectOptions = new MqttConnectOptions(); + mqttConnectOptions.setAutomaticReconnect(true); + mqttConnectOptions.setCleanSession(true); + mqttConnectOptions.setConnectionTimeout(10); + + try { + mMqttClient.connect(mqttConnectOptions, null, new IMqttActionListener() { + @Override + public void onSuccess(IMqttToken asyncActionToken) { + Toast.makeText(MainActivity.this, "Successfully Connected", Toast.LENGTH_SHORT).show(); + Log.i(TAG, "Successfully connected to MQTT broker"); + try { + // subscribe to all command-type messages directed at this robot + mMqttClient.subscribe("temi/" + sSerialNumber + "/command/#", 0); + } catch (MqttException e) { + e.printStackTrace(); + } + + // start a background task that periodically sends robot status information + // to the MQTT broker + sHandler.post(periodicTask); + } + + @Override + public void onFailure(IMqttToken asyncActionToken, Throwable exception) { + Toast.makeText(MainActivity.this, "Failed to Connect", Toast.LENGTH_SHORT).show(); + Log.i(TAG, "Failed to connect to MQTT broker"); + } + }); + } catch (MqttException e) { + e.printStackTrace(); + } + } + + /** + * Parses MQTT messages received by this client. + * @param topic Message topic + * @param payload Message payload + * @throws JSONException + */ private void parseMessage(String topic, JSONObject payload) throws JSONException { String[] topicTree = topic.split("/"); - if (topicTree.length <= 4) { - Log.d(TAG, "Invalid topic: " + topic); - } String robotID = topicTree[1]; String type = topicTree[2]; String category = topicTree[3]; - String command = topicTree[4]; Log.d(TAG, "Robot-ID: " + robotID); Log.d(TAG, "Type: " + type); Log.d(TAG, "Category: " + category); - Log.d(TAG, "Command: " + command); - - if (robotID.equals(serialNumber)) { + if (robotID.equals(sSerialNumber)) { switch (category) { case "waypoint": - parseWaypoint(command, payload); + parseWaypoint(topicTree[4], payload); break; + case "move": - parseMove(command, payload); + parseMove(topicTree[4], payload); break; - case "info": - try { - robotPublishStatus(); - } catch (JSONException e) { - e.printStackTrace(); - } + + case "call": + startCall(); break; + default: - Log.d(TAG, "Invalid topic: " + topic); + Log.i(TAG, "Invalid topic: " + topic); break; } - } else { - Log.d(TAG, "This Robot-ID: " + serialNumber); } } + /** + * Parses Waypoint messages. + * @param command Command type + * @param payload Message Payload + * @throws JSONException + */ private void parseWaypoint(String command, JSONObject payload) throws JSONException { - String location_name = payload.getString("location"); - Log.v(TAG, "[CMD][WAYPOINT] " + command + " | " + location_name); + String locationName = payload.getString("location"); switch (command) { case "save": - robot.saveLocation(location_name); + sRobot.saveLocation(locationName); break; + case "delete": - robot.deleteLocation(payload.getString(location_name)); - break; - case "get": - // TBD + sRobot.deleteLocation(payload.getString(locationName)); break; + case "goto": // check that the location exists, then go to that location - for (String location : robot.getLocations()){ - if (location.equals(location_name.toLowerCase().trim())) { - robot.goTo(location_name.toLowerCase().trim()); + for (String location : sRobot.getLocations()){ + if (location.equals(locationName.toLowerCase().trim())) { + sRobot.goTo(locationName.toLowerCase().trim()); } } break; + default: - Log.v(TAG, "[WAYPOINT] Unknown Locations Command"); + Log.i(TAG, "[WAYPOINT] Unknown Locations Command"); break; } } + /** + * Parses Move messages. + * @param command Command type + * @param payload Message Payload + * @throws JSONException + */ private void parseMove(String command, JSONObject payload) throws JSONException { - Log.v(TAG, "[CMD][MOVE] " + command); - switch (command) { case "joystick": float x = Float.parseFloat(payload.getString("x")); float y = Float.parseFloat(payload.getString("y")); - robot.skidJoy(x, y); + sRobot.skidJoy(x, y); break; + case "forward": - robot.skidJoy(+1.0F, 0.0F); + sRobot.skidJoy(+1.0F, 0.0F); break; + case "backward": - robot.skidJoy(-1.0F, 0.0F); + sRobot.skidJoy(-1.0F, 0.0F); break; + case "turn_by": - robot.turnBy(Integer.parseInt(payload.getString("angle"))); + sRobot.turnBy(Integer.parseInt(payload.getString("angle"))); break; + case "tilt": - robot.tiltAngle(Integer.parseInt(payload.getString("angle"))); + sRobot.tiltAngle(Integer.parseInt(payload.getString("angle"))); break; + case "tilt_by": - robot.tiltBy(Integer.parseInt(payload.getString("angle"))); + sRobot.tiltBy(Integer.parseInt(payload.getString("angle"))); break; + case "stop": - robot.stopMovement(); + sRobot.stopMovement(); break; + default: - Log.v(TAG, "[MOVE] Unknown Movement Command"); + Log.i(TAG, "[MOVE] Unknown Movement Command"); break; } } - private void robotPublishStatus() throws JSONException { - JSONObject payload = new JSONObject(); - JSONArray waypointArray = new JSONArray(); - - List waypointList = robot.getLocations(); - - // collect all waypoints - for (String waypoint : waypointList) { - waypointArray.put(waypoint); - Log.v(TAG, waypoint); - } + private void startCall() { + Log.v(TAG, "[CMD][CALL]"); - // generate payload - payload.put("waypoint_list", waypointArray); - payload.put("battery_percentage", Objects.requireNonNull(robot.getBatteryData()).getBatteryPercentage()); + // Build options object for joining the conference. The SDK will merge the default + // one we set earlier and this one when joining. + JitsiMeetConferenceOptions options + = new JitsiMeetConferenceOptions.Builder() + .setRoom("temi-" + sSerialNumber) + .build(); - try { - MqttMessage message = new MqttMessage(payload.toString().getBytes(StandardCharsets.UTF_8)); - mqttClient.publish("temi/" + serialNumber + "/status/info", message); - } catch (MqttException e) { - e.printStackTrace(); - } + // Launch the new activity with the given options. The launch() method takes care + // of creating the required Intent and passing the options. + JitsiMeetActivity.launch(this, options); } } diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index cc421ef..2819175 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -5,19 +5,37 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="#000000" + android:focusable="true" + android:focusableInTouchMode="true" tools:context=".MainActivity">