aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/img/icon.pngbin0 -> 21396 bytes
-rw-r--r--src/index.html61
-rw-r--r--src/js/hoverControlModule.js75
-rw-r--r--src/js/main.js158
-rw-r--r--src/js/notification.js83
-rw-r--r--src/manifest.webmanifest16
-rw-r--r--src/scss/styles.scss280
-rw-r--r--src/service-worker.js61
8 files changed, 734 insertions, 0 deletions
diff --git a/src/img/icon.png b/src/img/icon.png
new file mode 100644
index 0000000..be75d96
--- /dev/null
+++ b/src/img/icon.png
Binary files differ
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..fb23d7e
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html lang="en" dir="ltr">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="viewport-fit=cover,width=device-width, initial-scale=1, shrink-to-fit=no,initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+ <meta name="HandheldFriendly" content="true" />
+
+ <title>HOVER:BIT Controller</title>
+ <link rel="canonical" rel="https://jakobst1n.github.io/hoverbit-ble/">
+
+ <link rel="stylesheet" type="text/css" href="./scss/styles.scss">
+ <link rel="manifest" href="manifest.webmanifest" crossorigin="use-credentials">
+
+ <link rel="preconnect" href="https://fonts.gstatic.com">
+ <link href="https://fonts.googleapis.com/css2?family=Kufam&display=swap" rel="stylesheet">
+ </head>
+ <body class="">
+ <!-- <h1>HOVER:BIT Controller</h1> -->
+
+ <div class="landscape-warning">
+ <div class="landscape-warning-content">
+ <i class="fas fa-sync-alt"></i>
+ <h1>Please use landscape mode</h1>
+ <button id="btn_ignore_landscape_warning">Ignore</button>
+ </div>
+ </div>
+
+ <div class="top-buttons">
+ </div>
+
+ <button class="button-center button-center-top" id="btn_connect">CONNECT</button>
+ <button class="button-center button-center-top" id="btn_disconnect">DISCONNECT</button>
+
+ <div class="acc-string"><pre>T: 0, R: 0</pre></div>
+
+ <button class="button-center button-center-bottom" id="btn_arm">ARM</button>
+ <button class="button-center button-center-bottom" id="btn_disarm">DISARM</button>
+
+ <div class="joystick joystick-left"></div>
+ <div class="joystick joystick-right"></div>
+
+ <div class="statusline">
+ <span class="statusline-item connection">
+ <i class="fab fa-bluetooth-b"></i>
+ </span>
+ <span class="statusline-item arm">
+ <i class="fas fa-exclamation-triangle"></i>
+ </span>
+ <span class="notification-area"></span>
+ <span class="statusline-item ping">
+ <i class="fas fa-broadcast-tower"></i>
+ </span>
+ <span class="statusline-item battery">
+ <i class="fas fa-car-battery"></i>
+ <span class="battery-status">0%</span>
+ </span>
+ </div>
+
+ <script type="text/javascript" src="./js/main.js"></script>
+ </body>
+</html>
diff --git a/src/js/hoverControlModule.js b/src/js/hoverControlModule.js
new file mode 100644
index 0000000..44c06fb
--- /dev/null
+++ b/src/js/hoverControlModule.js
@@ -0,0 +1,75 @@
+import { notif_alert, notif_warn, notif_info, notif_success } from './notification';
+
+export default class hoverControlModule {
+ #throttle = 0;
+ #throttleAcc = 0;
+ #rudder = 0;
+ #rudderAcc = 0;
+ #arm = 0;
+ #armAcc = 0;
+
+ constructor() {}
+
+ acc(accString) {
+ accString.match(/[A-Z][-,0-9]+/g).forEach((item, i) => {
+ switch (item.substring(0, 1)) {
+ case "T":
+ this.#throttleAcc = parseInt(item.substring(1, item.length));
+ break;
+ case "R":
+ this.#rudderAcc = parseInt(item.substring(1, item.length));
+ break;
+ case "A":
+ this.#armAcc = parseInt(item.substring(1, item.length)) == 1;
+ if (this.#armAcc) {
+ document.body.classList.add("armed");
+ } else {
+ document.body.classList.remove("armed");
+ }
+ break;
+ case "S":
+ break;
+ default:
+ console.log(`Unkown acc: ${item}`);
+ }
+ });
+ document.querySelector(".acc-string pre").innerHTML = `T: ${this.#throttleAcc}, R: ${this.#rudderAcc}`;
+ }
+
+ reset() {
+ this.setArm(0);
+ this.setThrottle(0);
+ this.setRudder(0);
+ }
+
+ setThrottle(throttle) {
+ if (!this.#armAcc) { return; }
+ if (throttle > 100) { throttle = 100; }
+ if (throttle < 0) { throttle = 0; }
+ this.#throttle = throttle;
+ }
+ getThrottle() {
+ return this.#throttle;
+ }
+
+ setRudder(rudder) {
+ if (!this.#armAcc) { return; }
+ if (rudder > 90) { rudder = 90; }
+ if (rudder < -90) { rudder = -90; }
+ this.#rudder = rudder;
+ }
+ getRudder() {
+ return this.#rudder;
+ }
+
+ setArm(arm) {
+ this.#arm = arm;
+ if (!this.#arm) {
+ this.#throttle = 0;
+ this.#rudder = 0;
+ }
+ }
+ getArm() {
+ return this.#arm;
+ }
+}
diff --git a/src/js/main.js b/src/js/main.js
new file mode 100644
index 0000000..1c26af9
--- /dev/null
+++ b/src/js/main.js
@@ -0,0 +1,158 @@
+import nipplejs from 'nipplejs';
+import { requestMicrobit, getServices } from 'microbit-web-bluetooth';
+import hoverControlModule from './hoverControlModule';
+import { notif_alert, notif_warn, notif_info, notif_success } from './notification';
+
+let sw = "service-worker.js";
+//if (navigator.serviceWorker) {
+// navigator.serviceWorker.register(sw, {scope: '/hoverbit-ble/'});
+//}
+navigator.serviceWorker.register(
+ sw, {scope: '/hoverbit-ble/'}
+).then(registration => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing;
+ if (installingWorker == null) { return; }
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === "installed") {
+ if (navigator.serviceWorker.controller) {
+ notif_info("New content is available, relaunch the app to install it.");
+ } else {
+ notif_success("Content is cached for offline use.");
+ }
+ }
+ };
+ };
+}).catch(error => {
+ notif_alert("Could not install service worker...");
+ console.error("Error during service worker registration:", error);
+});
+
+document.getElementById("btn_ignore_landscape_warning").addEventListener("click", () => {
+ document.body.classList.add("ignore-landscape-warning");
+});
+
+/* Define and initialize things */
+let hoverControl = new hoverControlModule();
+let bluetoothDevice;
+let bluetoothDeviceServices;
+
+let joystickLeft = nipplejs.create({
+ zone: document.querySelector(".joystick-left"),
+ size: 200,
+ position: {left: '50%', bottom: '50%'},
+ mode: "static",
+ lockX: true
+});
+let joystickRight = nipplejs.create({
+ zone: document.querySelector(".joystick-right"),
+ size: 200,
+ position: {left: '50%', bottom: '50%'},
+ mode: "static",
+ lockY: true
+});
+
+/* Setup event_listeners */
+joystickLeft.on("move", (evt, data) => {
+ let rudder = ((data.distance * 90) / 100);
+ if (data.angle.degree > 90) { rudder = rudder * -1; }
+ hoverControl.setRudder(rudder);
+});
+joystickLeft.on("end", (evt, data) => {
+ hoverControl.setRudder(0);
+});
+
+joystickRight.on("move", (evt, data) => {
+ let throttle = data.distance;
+ if (data.angle.degree > 90) { throttle = 0; }
+ hoverControl.setThrottle(throttle);
+});
+joystickRight.on("end", (evt, data) => {
+ hoverControl.setThrottle(0);
+});
+
+document.getElementById("btn_arm").addEventListener("click", () => {
+ hoverControl.setArm(true);
+});
+
+document.getElementById("btn_disarm").addEventListener("click", () => {
+ hoverControl.setArm(false);
+});
+
+document.querySelector("#btn_disconnect").addEventListener("click", () => {
+ hoverControl.reset();
+ bluetoothDevice.gatt.disconnect();
+});
+
+let intervalConnectionChecker = setInterval(() => {
+ if (bluetoothDevice !== undefined && bluetoothDevice) {
+ if (bluetoothDevice.gatt.connected) {
+ document.body.classList.add("connected");
+ } else {
+ document.body.classList.remove("connected");
+ document.body.classList.remove("armed");
+ }
+ } else if (bluetoothDevice !== undefined) {
+ bluetoothDevice.gatt.reconnect();
+ }
+}, 500);
+
+let intervalSendCommands = setInterval(async() => {
+ if (bluetoothDevice !== undefined && bluetoothDevice) {
+ if (bluetoothDevice.gatt.connected && bluetoothDeviceServices.uartService) {
+ let command =
+ "T" + hoverControl.getThrottle().toString() +
+ "R" + hoverControl.getRudder().toString() +
+ "A" + (hoverControl.getArm() ? "1" : "0") +
+ "S0" +
+ ":";
+ await bluetoothDeviceServices.uartService.sendText(command);
+ }
+ }
+}, 70);
+
+function receiveText(event) {
+ /* Just make the ping symbol reappear. */
+ var elm = document.querySelector(".ping i");
+ var newone = elm.cloneNode(true);
+ elm.parentNode.replaceChild(newone, elm);
+
+ /* Actually handle received text. */
+ if ((event.detail).indexOf(":") != -1) {
+ let parts = (event.detail).split(":");
+
+ if (parts[0] == "B") {
+ document.querySelector(".battery-status").innerHTML = parts[1] + "mV";
+ } else if (parts[0] == "ACC") {
+ hoverControl.acc(parts[1]);
+ } else {
+ console.log(parts);
+ }
+ } else {
+ notif_warn("Received weird data from MICRO:BIT...");
+ console.log(`Received unknown: ${event.detail}`);
+ }
+}
+
+document.getElementById("btn_connect").onclick = async () => {
+ if (bluetoothDevice !== undefined && bluetoothDevice.gatt.connected) {
+ bluetoothDevice.disconnect();
+ }
+
+ const device = await requestMicrobit(window.navigator.bluetooth);
+ bluetoothDevice = device;
+
+ if (device) {
+ hoverControl.reset();
+ const services = await getServices(device);
+ bluetoothDeviceServices = services;
+
+ if (bluetoothDeviceServices.deviceInformationService) {
+ // logJson(await services.deviceInformationService.readDeviceInformation());
+ }
+
+ if (services.uartService) {
+ services.uartService.addEventListener("receiveText", receiveText);
+ }
+ }
+}
diff --git a/src/js/notification.js b/src/js/notification.js
new file mode 100644
index 0000000..c2eb14c
--- /dev/null
+++ b/src/js/notification.js
@@ -0,0 +1,83 @@
+let notif_queue = [];
+
+function notif(notif_c) {
+ let notification_area = document.querySelector(".statusline .notification-area");
+
+ if (notification_area.querySelector(".notification") === null) {
+ let notif_elem = document.createElement("div");
+ notif_elem.className = "notification";
+ notif_elem.appendChild(notif_c[0]);
+ notif_elem.appendChild(notif_c[1]);
+
+ notification_area.appendChild(notif_elem);
+
+ setTimeout(() => {
+ notification_area.removeChild(notif_elem);
+ if (notif_queue.length > 0) {
+ notif(notif_queue.pop());
+ }
+ }, 5000);
+ } else {
+ notif_queue.push(notif_c);
+ }
+}
+
+export function notif_alert(alert_str) {
+ let div = document.createElement("div");
+ div.className = "notification-content";
+
+ let text = document.createElement("p");
+ text.innerHTML = alert_str;
+ div.appendChild(text);
+
+ let icon = document.createElement("i");
+ icon.className = "alert fas fa-exclamation-triangle";
+ div.appendChild(icon);
+
+ notif([icon, div]);
+}
+
+export function notif_warn(alert_str) {
+ let div = document.createElement("div");
+ div.className = "notification-content";
+
+ let text = document.createElement("p");
+ text.innerHTML = alert_str;
+ div.appendChild(text);
+
+ let icon = document.createElement("i");
+ icon.className = "warning fas fa-exclamation-triangle";
+ div.appendChild(icon);
+
+ notif([icon, div]);
+}
+
+export function notif_info(info_str) {
+ let div = document.createElement("div");
+ div.className = "notification-content";
+
+ let text = document.createElement("p");
+ text.innerHTML = info_str;
+ div.appendChild(text);
+
+ let icon = document.createElement("i");
+ icon.className = "info fas fa-info-circle";
+ div.appendChild(icon);
+
+ notif([icon, div]);
+}
+
+export function notif_success(success_str) {
+ let div = document.createElement("div");
+ div.className = "notification-content";
+
+ let text = document.createElement("p");
+ text.innerHTML = success_str;
+ div.appendChild(text);
+
+ let icon = document.createElement("i");
+ icon.className = "success fas fa-check-circle";
+ div.appendChild(icon);
+
+ notif([icon, div]);
+}
diff --git a/src/manifest.webmanifest b/src/manifest.webmanifest
new file mode 100644
index 0000000..4306e2b
--- /dev/null
+++ b/src/manifest.webmanifest
@@ -0,0 +1,16 @@
+{
+ "background_color": "#454545",
+ "theme_color": "#5ac775",
+ "name": "HOVERBIT Controller",
+ "short_name": "HOVERBIT BLE",
+ "display": "fullscreen",
+ "start_url": "/hoverbit-ble/",
+ "orientation": "landscape",
+ "icons": [
+ {
+ "src": "img/icon.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/src/scss/styles.scss b/src/scss/styles.scss
new file mode 100644
index 0000000..b72acef
--- /dev/null
+++ b/src/scss/styles.scss
@@ -0,0 +1,280 @@
+@import '@fortawesome/fontawesome-free/css/all.css';
+
+$background-base: #454545;
+$foreground-base: #9c9c9c;
+$background-z1: #333333;
+$foreground-z1: #b5b5b5;
+$background-z2: #252525;
+$foreground-z2: #b5b5b5;
+
+$color-danger: #c32222;
+$color-info: #0374a8;
+$color-success: #008000;
+$color-warning: #d99b0b;
+
+html, body {
+ margin: 0;
+ padding: 0;
+ /* width: 100%;
+ height: 100%; */
+ font-family: monospace;
+ overflow: hidden;
+ font-family: 'Kufam', cursive;
+}
+
+body {
+ // margin: 10px;
+ background-color: $background-base;
+ color: $foreground-base;
+}
+
+h1 {
+ font-size: 10px;
+ margin: 0;
+ color: $foreground-base;
+}
+
+.statusline {
+ position: absolute;
+ display: flex;
+ bottom: 0;
+ left: 0;
+ height: 40px;
+ /* padding-left: 5px; */
+ width: calc(100%);
+ font-size: 12px;
+ margin: 0;
+ align-items: center;
+
+ background-color: $background-z1;
+ i {
+ font-size: 14px;
+ margin-right: 5px;
+ }
+
+ &-item {
+ display: none;
+ align-items: flex-start;
+ margin-left: 5px;
+ margin-right: 5px;
+ font-size: 13px;
+
+ color: $foreground-z1;
+
+ .connected & { display: flex; }
+ }
+
+ &-item:first-child {
+ margin-left: 10px;
+ margin-left: calc(env(safe-area-inset-left) + 10px);
+ }
+
+ &-item:last-child {
+ margin-right: 10px;
+ margin-right: calc(env(safe-area-inset-right) + 10px);
+ }
+}
+
+.connection {
+ display: flex;
+ color: $color-danger;
+
+ &:after {
+ display: block;
+ content: "DISCONNECTED";
+ }
+
+ .connected & { color: $color-success; }
+ .connected &:after {
+ display: block;
+ content: "CONNECTED";
+ }
+}
+
+.arm {
+ color: $color-danger;
+
+ &:after {
+ content: 'DISARMED';
+ display: block;
+ }
+
+ .armed & {color: $color-success; }
+ .armed &:after {
+ content: 'ARMED';
+ display: block;
+ }
+}
+
+.ping {
+ margin-left: auto;
+
+ i {
+ -webkit-animation: ping-fade-out 2s forwards; /* Safari 4+ */
+ -moz-animation: ping-fade-out 2s forwards; /* Fx 2+ */
+ -o-animation: ping-fade-out 2s forwards; /* Opera 12+ */
+ animation: ping-fade-out 2s forwards; /* IE 10+, Fx 29+ */
+ }
+
+ @-webkit-keyframes ping-fade-out {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+ }
+}
+
+.acc-string {
+ display: none;
+ position: absolute;
+ top: 60px;
+ left: calc(50% - 50px - 10px);
+ width: 100px;
+ text-align: center;
+ background-color: #272727;
+ padding: 0 10px;
+ border-radius: 10px;
+ font-size: 9px;
+
+ .connected & { display: block; }
+}
+
+.button-center {
+ position: absolute;
+ display: none;
+ margin-top: 5px;
+ border: none;
+ background-color: $background-z1;
+ color: $foreground-z1;
+ padding: 10px;
+ width: 140px;
+ height: 40px;
+ left: calc(50% - 70px);
+ font-weight: 400;
+ font-size: 15px;
+
+ .armed & { display: block; }
+ &-top { top: 10px; }
+ &-bottom { bottom: 50px; }
+}
+
+#btn_connect { display: block; }
+.connected #btn_connect { display: none; }
+.connected #btn_disconnect { display: block; }
+.connected #btn_arm { display: block; }
+.armed #btn_arm { display: none; }
+
+#btn_connect, #btn_disconnect {
+ text-shadow: 0 0 5px #c3c3c3, 0 0 10px #636363;
+}
+#btn_arm, #btn_disarm {
+ color: red;
+ text-shadow: 0 0 5px #c53f3f, 0 0 10px #c53f3f;
+}
+#btn_disarm{
+ color: #00ff15;
+ text-shadow: 0 0 5px #11b91f, 0 0 10px #297b30;
+}
+
+.joystick {
+ position: absolute;
+ width: 200px;
+ height: 200px;
+ top: calc(50% - 100px - 10px); /* 50% - half height - statusline height */
+ /* border: 1px solid pink; */
+
+ &-left { left: calc(25% - 100px); }
+ &-right { left: calc(75% - 100px); }
+}
+
+
+.landscape-warning {
+ display: none;
+ position: absolute;
+ z-index: 9999999;
+ width: 100%;
+ height: 100%;
+ padding-top: 50%;
+ background-color: $background-base;
+ text-align: center;
+
+ &-content {
+ position: relative;
+ top: -50px;
+ }
+ i { font-size: 5em; }
+ h1 {
+ font-size: 1em;
+ margin: 10px;
+ }
+ button {
+ border: none;
+ background-color: $background-z1;
+ color: $foreground-z1;
+ padding: 10px;
+ width: 140px;
+ height: 40px;
+ }
+
+ .ignore-landscape-warning & { display: none !important; }
+}
+
+@media screen and (orientation:portrait) {
+ .landscape-warning {
+ display: block;
+ }
+}
+
+.notification {
+ background-color: $background-z2;
+ color: $foreground-z2;
+ overflow: hidden;
+ display: flex;
+ align-items: baseline;
+
+ &-area {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ margin-left: 5px;
+ margin-right: 5px;
+
+ -webkit-animation: fade-in 0.5s linear; /* Safari 4+ */
+ -moz-animation: fade-in 0.5s linear; /* Fx 2+ */
+ -o-animation: fade-in 0.5s linear; /* Opera 12+ */
+ animation: fade-in 0.5s linear; /* IE 10+, Fx 29+ */
+ }
+
+ &-content {
+ margin-left: 5px;
+ margin-right: 5px;
+ width: 100%;
+ overflow: hidden;
+ }
+
+ i {
+ margin-left: 5px;
+ margin-right: 5px;
+ }
+
+ i.alert { color: $color-danger; }
+ i.info { color: $color-info; }
+ i.success { color: $color-success; }
+ i.warning { color: $color-warning; }
+
+ p {
+ width: max-content;
+ -webkit-animation: scroll-text 5s linear; /* Safari 4+ */
+ -moz-animation: scroll-text 5s linear; /* Fx 2+ */
+ -o-animation: scroll-text 5s linear; /* Opera 12+ */
+ animation: scroll-text 5s linear; /* IE 10+, Fx 29+ */
+ }
+
+ @-webkit-keyframes scroll-text {
+ 0% { margin-left: 100%; }
+ 100% { margin-left: -100%; }
+ }
+
+ @-webkit-keyframes fade-in {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+ }
+}
diff --git a/src/service-worker.js b/src/service-worker.js
new file mode 100644
index 0000000..bc3f42d
--- /dev/null
+++ b/src/service-worker.js
@@ -0,0 +1,61 @@
+var APP_PREFIX = 'hoverbitcontroller' // Identifier for this app (this needs to be consistent across every cache update)
+var VERSION = 'version_01' // Version of the off-line cache (change this value everytime you want to update cache)
+var CACHE_NAME = APP_PREFIX + VERSION
+var URLS = [ // Add URL you want to cache in this list.
+ '/hoverbit-ble/', // If you have separate JS/CSS files,
+ '/hoverbit-ble/index.html', // add path to those files here
+ '/hoverbit-ble/styles.css',
+ '/hoverbit-ble/microbit.umd.js',
+ '/hoverbit-ble/script.js'
+]
+
+// Respond with cached resources
+self.addEventListener('fetch', function (e) {
+ console.log('fetch request : ' + e.request.url)
+ e.respondWith(
+ caches.match(e.request).then(function (request) {
+ if (request) { // if cache is available, respond with cache
+ console.log('responding with cache : ' + e.request.url)
+ return request
+ } else { // if there are no cache, try fetching request
+ console.log('file is not cached, fetching : ' + e.request.url)
+ return fetch(e.request)
+ }
+
+ // You can omit if/else for console.log & put one line below like this too.
+ // return request || fetch(e.request)
+ })
+ )
+})
+
+// Cache resources
+self.addEventListener('install', function (e) {
+ e.waitUntil(
+ caches.open(CACHE_NAME).then(function (cache) {
+ console.log('installing cache : ' + CACHE_NAME)
+ return cache.addAll(URLS)
+ })
+ )
+})
+
+// Delete outdated caches
+self.addEventListener('activate', function (e) {
+ e.waitUntil(
+ caches.keys().then(function (keyList) {
+ // `keyList` contains all cache names under your username.github.io
+ // filter out ones that has this app prefix to create white list
+ var cacheWhitelist = keyList.filter(function (key) {
+ return key.indexOf(APP_PREFIX)
+ })
+ // add current cache name to white list
+ cacheWhitelist.push(CACHE_NAME)
+
+ return Promise.all(keyList.map(function (key, i) {
+ if (cacheWhitelist.indexOf(key) === -1) {
+ console.log('deleting cache : ' + keyList[i] )
+ return caches.delete(keyList[i])
+ }
+ }))
+ })
+ )
+})