feat: working basic joystick control + with mouse
This commit is contained in:
parent
dc35b13ab4
commit
0657dff26e
14 changed files with 5491 additions and 5 deletions
4917
package-lock.json
generated
Normal file
4917
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
@ -12,13 +12,21 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^1.0.0",
|
||||
"@sveltejs/adapter-static": "^1.0.5",
|
||||
"@sveltejs/kit": "^1.0.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"postcss": "^8.4.21",
|
||||
"sass": "^1.58.0",
|
||||
"svelte": "^3.54.0",
|
||||
"svelte-check": "^3.0.1",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^4.0.0"
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"two.js": "^0.8.10"
|
||||
}
|
||||
}
|
||||
|
|
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
38
src/app.css
Normal file
38
src/app.css
Normal file
|
@ -0,0 +1,38 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
background: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.controller-back {
|
||||
width: 10em;
|
||||
height: 10em;
|
||||
background: red;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
.controller-joystick {
|
||||
position: absolute;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
.controller-joystick-ins {
|
||||
position: relative;
|
||||
/* left: -50%; */
|
||||
/* top: -45%; */
|
||||
margin-top: -50%;
|
||||
margin-left: -50%;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background: blue;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
#joy2 {
|
||||
/* width: 10em; */
|
||||
/* heigth: 10em; */
|
||||
background: gray;
|
||||
}
|
6
src/hook.js
Normal file
6
src/hook.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event, {
|
||||
ssr: false,
|
||||
});
|
||||
return response;
|
||||
};
|
3
src/hook.server.js
Normal file
3
src/hook.server.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export async function handle({ event, resolve }) {
|
||||
return resolve(event, { ssr: false });
|
||||
}
|
327
src/lib/Controller.svelte
Normal file
327
src/lib/Controller.svelte
Normal file
|
@ -0,0 +1,327 @@
|
|||
<script>
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
function sign(x) {
|
||||
if (x == 0) { return 0; }
|
||||
if (x > 0) { return 1; }
|
||||
return -1;
|
||||
}
|
||||
import { onMount } from 'svelte';
|
||||
import Two from 'two.js';
|
||||
import WebSocketService from '$lib/WebSocketService.js'
|
||||
|
||||
const realSendMode = true;
|
||||
const maxSpeed = 50;
|
||||
|
||||
const buttonBindings = ['x','o','triangle','square','l1','r1','l2','r2','share','options','home','leftstickpress','rightstickpress']
|
||||
const axisBindings = ['left_joystick_x','left_joystick_y','l2','right_joystick_x','right_joystick_y','r2','cross_x','cross_y']
|
||||
|
||||
function getGamepadState(gpIndex) {
|
||||
let tmpState = navigator.getGamepads()[currentGamepadIndex];
|
||||
|
||||
return {
|
||||
buttons: tmpState.buttons.map(b => {
|
||||
return {pressed: b.pressed, touched: b.touched, value: b.value}
|
||||
}),
|
||||
axes: tmpState.axes
|
||||
}
|
||||
}
|
||||
|
||||
let l2initialized;
|
||||
export let data;
|
||||
export let gpState;
|
||||
export let gpDictState;
|
||||
export let prettyGpState;
|
||||
export let direction;
|
||||
export let normalizedVec = {x: 0, y: 0};
|
||||
export let angleFullRangeDeg = 0;
|
||||
export let finalSpeed = 0;
|
||||
|
||||
export let mouseControlled;
|
||||
|
||||
const zone_directions = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"];
|
||||
let twoScene = null;
|
||||
let zonesGroup = null;
|
||||
let leftJoystick = {pointer: null, bg: null};
|
||||
let rightJoystick = {pointer: null, bg: null};
|
||||
|
||||
let lastGpState = null;
|
||||
let currentWs = null;
|
||||
|
||||
let currentGamepadIndex = -1;
|
||||
|
||||
function initGamepads() {
|
||||
console.log("init gamepads")
|
||||
window.addEventListener("gamepadconnected", (e) => {
|
||||
const gp = navigator.getGamepads()[e.gamepad.index];
|
||||
currentGamepadIndex = gp.index;
|
||||
l2initialized = false;
|
||||
console.log(`Gamepad connected at index ${gp.index}: ${gp.id} with ${gp.buttons.length} buttons, ${gp.axes.length} axes.`);
|
||||
});
|
||||
window.addEventListener("gamepaddisconnected", (e) => {
|
||||
console.log(e.gamepad);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function convert(s) {
|
||||
// -1 1
|
||||
// 0 2
|
||||
return ((s+1)/2)*100
|
||||
}
|
||||
|
||||
function getGamepadStateAsDict(arrayState) {
|
||||
return {
|
||||
axes: Object.fromEntries(axisBindings.map((n, i) => [n, arrayState.axes[i]])),
|
||||
buttons: Object.fromEntries(buttonBindings.map((n, i) => [n, arrayState.buttons[i]]))
|
||||
}
|
||||
}
|
||||
|
||||
function renderJoystick() {
|
||||
const scaleFactor = 100;
|
||||
rightJoystick.pointer.translation.x = 200 + normalizedVec.x*scaleFactor;
|
||||
rightJoystick.pointer.translation.y = 200 + normalizedVec.y*scaleFactor;
|
||||
}
|
||||
|
||||
function updateNavigation(angleFullRange) {
|
||||
const intensity = Math.hypot(normalizedVec.x, normalizedVec.y)
|
||||
if (intensity == 0) {
|
||||
currentWs.send("stop_robot", {})
|
||||
zonesGroup.children.forEach(zone => {
|
||||
zone.opacity = 1
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
function getZoneDirection(angle) {
|
||||
const nbOfZones = zone_directions.length;
|
||||
const delta = 2*Math.PI/nbOfZones;
|
||||
const offset = delta/2;
|
||||
for (let i = 0; i < nbOfZones; i++) {
|
||||
let lb = i*delta+offset;
|
||||
let ub = (i+1)*delta+offset;
|
||||
// console.log(lb, angle, ub)
|
||||
if (lb >= angle && angle <= ub) {
|
||||
return zone_directions[i];
|
||||
}
|
||||
}
|
||||
return "E";
|
||||
}
|
||||
|
||||
|
||||
// let angleFullRange = -Math.abs(Math.acos(normalizedVec.x))*sign(Math.asin(normalizedVec.y));
|
||||
angleFullRangeDeg = angleFullRange * 180/Math.PI;
|
||||
|
||||
direction = getZoneDirection(angleFullRange)
|
||||
|
||||
zonesGroup.children.forEach((zone, i) => {
|
||||
if (zone_directions.indexOf(direction) == i) {
|
||||
zone.opacity = 0.5
|
||||
} else {
|
||||
zone.opacity = 1
|
||||
}
|
||||
})
|
||||
// console.log("new angle", angleFullRange * 180/Math.PI, direction)
|
||||
|
||||
if (realSendMode) {
|
||||
currentWs.send(
|
||||
"set_direction",
|
||||
{"direction": direction}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function updateSpeed(speed) {
|
||||
// console.log("speed", speed)
|
||||
finalSpeed = clamp(speed, 0, maxSpeed)
|
||||
// set speed
|
||||
if (realSendMode) {
|
||||
currentWs.send(
|
||||
"set_speed",
|
||||
{
|
||||
"speed": finalSpeed
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function onGamepadChange(receivedState) {
|
||||
gpDictState = getGamepadStateAsDict(lastGpState);
|
||||
|
||||
if (!l2initialized && gpDictState.axes.l2 != 0) {
|
||||
l2initialized = true;
|
||||
}
|
||||
|
||||
prettyGpState = JSON.stringify(gpDictState, " ", 4);
|
||||
|
||||
// joy.style.left = convert(gpDictState.axes.right_joystick_x) + "%"
|
||||
// joy.style.top = convert(gpDictState.axes.right_joystick_y) + "%"
|
||||
|
||||
// pre process the right joystick
|
||||
let vec = {x: gpDictState.axes.right_joystick_x, y: gpDictState.axes.right_joystick_y};
|
||||
let angle = Math.atan2(vec.y, vec.x);
|
||||
let reach = clamp(Math.hypot(vec.x, vec.y), 0, 1);
|
||||
|
||||
// create the normalized vector
|
||||
normalizedVec = {x: reach*Math.cos(angle), y: reach*Math.sin(angle)};
|
||||
|
||||
handleJoystickChange();
|
||||
}
|
||||
|
||||
function handleJoystickChange() {
|
||||
let reach = Math.hypot(normalizedVec.x, normalizedVec.y);
|
||||
// convert angle that is sometimes from 0 to -180 and sometimes from 0 to 180 deg
|
||||
// to something from 0 to 360 full circle
|
||||
let angle = Math.atan2(normalizedVec.y, normalizedVec.x);
|
||||
let angleFullRange = (reach == 0) ? 0 : (angle < 0) ? -angle : Math.PI + (Math.PI - angle);
|
||||
|
||||
console.log({
|
||||
angle: angle*180/Math.PI,
|
||||
angleFullRange: angleFullRange*180/Math.PI,
|
||||
reach, normalizedVec
|
||||
});
|
||||
|
||||
// console.log(vec.x, vec.y, angle, angle *180/Math.PI)
|
||||
|
||||
renderJoystick();
|
||||
updateNavigation(angleFullRange)
|
||||
|
||||
const maxSpeed = 50;
|
||||
let speedForced = l2initialized ? Math.round(((gpDictState.axes.l2 + 1)/2)*maxSpeed) : 0;
|
||||
let speedNormal = Math.round(0.50*reach*maxSpeed);
|
||||
updateSpeed(Math.max(speedNormal, speedForced));
|
||||
}
|
||||
|
||||
function areSameObjects(a, b) {
|
||||
return JSON.stringify(a) == JSON.stringify(b)
|
||||
}
|
||||
|
||||
function loop() {
|
||||
if (currentGamepadIndex >= 0) {
|
||||
let receivedState = getGamepadState(currentGamepadIndex);
|
||||
|
||||
// console.log(lastGpState == receivedState, lastGpState, JSON.stringify(lastGpState), JSON.stringify(receivedState))
|
||||
if (lastGpState == null || !areSameObjects(lastGpState, receivedState)) {
|
||||
// console.log("changed")
|
||||
lastGpState = receivedState;
|
||||
onGamepadChange(receivedState);
|
||||
twoScene.render();
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let sceneContainer = document.getElementById("joy2");
|
||||
twoScene = new Two({}).appendTo(sceneContainer);
|
||||
|
||||
rightJoystick.bg = new Two.Circle(
|
||||
200, 200, 100
|
||||
)
|
||||
twoScene.add(rightJoystick.bg)
|
||||
|
||||
rightJoystick.pointer = new Two.Circle(
|
||||
200, 200, 10
|
||||
);
|
||||
rightJoystick.pointer.fill = "red";
|
||||
rightJoystick.pointer.stroke = "rgba(0, 0, 0, 0.25)";
|
||||
|
||||
|
||||
let nbOfZones = 8;
|
||||
let delta = 2*Math.PI/nbOfZones;
|
||||
let offset = delta/2;
|
||||
|
||||
zonesGroup = new Two.Group();
|
||||
for (var i = 0; i < nbOfZones; i++) {
|
||||
let arc = new Two.ArcSegment(
|
||||
200, 200, 0, 100,
|
||||
-i*delta+offset, -(i+1)*delta+offset
|
||||
);
|
||||
arc.fill = "green";
|
||||
zonesGroup.add(arc);
|
||||
}
|
||||
|
||||
let mousePointer = new Two.Circle(0, 0, 4);
|
||||
|
||||
twoScene.add(zonesGroup);
|
||||
twoScene.add(rightJoystick.pointer);
|
||||
twoScene.add(mousePointer);
|
||||
|
||||
twoScene.render();
|
||||
|
||||
let controller_index = 0;
|
||||
|
||||
initGamepads();
|
||||
currentWs = new WebSocketService("http://192.168.1.128:1567");
|
||||
|
||||
currentWs.start()
|
||||
currentWs.on('connectionUpdated', 'connection', e => {
|
||||
console.log(e.detail);
|
||||
})
|
||||
|
||||
// handle mouse events
|
||||
sceneContainer.onmousemove = (e) => {
|
||||
if (!mouseControlled) {
|
||||
return
|
||||
}
|
||||
console.log(e);
|
||||
let rect = sceneContainer.getBoundingClientRect();
|
||||
let x = e.clientX - rect.left;
|
||||
let y = e.clientY - rect.top;
|
||||
mousePointer.translation.x = x;
|
||||
mousePointer.translation.y = y;
|
||||
// compute the position inside the circle
|
||||
let circle = {x: 200, y: 200, radius: 100};
|
||||
|
||||
let relVec = {x: x-circle.x, y: y-circle.y};
|
||||
|
||||
// console.log("rel circle", relVec);
|
||||
if (Math.hypot(relVec.x, relVec.y) < 2*circle.radius) {
|
||||
// console.log("inside circle")
|
||||
let vec = {x: relVec.x/circle.radius, y: relVec.y/circle.radius};
|
||||
let angle = Math.atan2(vec.y, vec.x);
|
||||
let reach = clamp(Math.hypot(vec.x, vec.y), 0, 1);
|
||||
normalizedVec = {x: reach*Math.cos(angle), y: reach*Math.sin(angle)};
|
||||
handleJoystickChange();
|
||||
}
|
||||
|
||||
twoScene.render();
|
||||
}
|
||||
sceneContainer.onmousedown = () => {
|
||||
mouseControlled = true;
|
||||
mousePointer.opacity = 1;
|
||||
}
|
||||
sceneContainer.onmouseup = () => {
|
||||
mouseControlled = false;
|
||||
normalizedVec = {x: 0, y: 0};
|
||||
handleJoystickChange();
|
||||
mousePointer.opacity = 0;
|
||||
twoScene.render();
|
||||
}
|
||||
|
||||
loop();
|
||||
})
|
||||
</script>
|
||||
|
||||
<h2>Controller</h2>
|
||||
<div class="grid grid-cols-2">
|
||||
<div>
|
||||
<pre>
|
||||
angle: {angleFullRangeDeg.toFixed(2)} deg
|
||||
dir: {direction}
|
||||
speed: {finalSpeed}
|
||||
vec: {normalizedVec.x.toFixed(4)} {normalizedVec.y.toFixed(4)}
|
||||
</pre>
|
||||
<pre style="opacity: 0.6">joystick: {prettyGpState}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<!-- <div class="controller-back"> -->
|
||||
<!-- <div id="joy" class="controller-joystick"> -->
|
||||
<!-- <div class="controller-joystick-ins"></div> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
<div id="joy2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
131
src/lib/WebSocketService.js
Normal file
131
src/lib/WebSocketService.js
Normal file
|
@ -0,0 +1,131 @@
|
|||
export default class WebSocketService extends EventTarget {
|
||||
constructor(address) {
|
||||
super()
|
||||
/* Set address */
|
||||
this.addressLocalStorageKey = 'WR_UI_SERVER_ADDRESS'
|
||||
this.address = address
|
||||
let lsAddress = window.localStorage.getItem(this.addressLocalStorageKey)
|
||||
if (lsAddress != null) { this.address = lsAddress}
|
||||
|
||||
/* Set identifier */
|
||||
this.identifierLocalStorageKey = 'WR_UI_CLIENT_IDENTIFIER'
|
||||
this.identifier = 'default_' + Date.now().toString()
|
||||
let lsIdentifier = window.localStorage.getItem(this.identifierLocalStorageKey)
|
||||
if (lsIdentifier != null && lsIdentifier != 'null') { this.identifier = lsIdentifier } else {
|
||||
window.localStorage.setItem(this.identifierLocalStorageKey, this.identifier)
|
||||
}
|
||||
|
||||
/* Various other things */
|
||||
this.recovery = false
|
||||
this.isConnected = false
|
||||
this.handlersList = []
|
||||
this.toSend = []
|
||||
}
|
||||
|
||||
start() {
|
||||
//console.log(this.isConnected)
|
||||
let addr = 'ws://' + this.address.replace('http://', '')
|
||||
addr += "?identifier=" + this.identifier
|
||||
if (this.isConnected) {
|
||||
this.recovery = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.ws = new WebSocket(addr)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
this.ws.onopen = () => {
|
||||
this.isConnected = true
|
||||
console.log(
|
||||
'%c > WebSocketService: opened (id: ' + this.identifier +')',
|
||||
'background: black; color: #00ff00; font-size: 1.1em'
|
||||
)
|
||||
this.dispatchEvent(new CustomEvent('connectionUpdated', { detail: true }))
|
||||
this.toSend.forEach(value => {
|
||||
this.send(value[0], value[1])
|
||||
})
|
||||
this.toSend = []
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
console.log(event.data)
|
||||
let parsed = JSON.parse(event.data)
|
||||
// this.dispatchEvent(new CustomEvent(
|
||||
// parsed['responseType'], { detail: parsed['data'] })
|
||||
// )
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (!this.recovery) {
|
||||
console.log('%c > WebSocketService: closed ', 'background: black; color: #00ff00; font-size: 1.1em')
|
||||
this.dispatchEvent(new CustomEvent('connectionUpdated', { detail: false }))
|
||||
}
|
||||
this.isConnected = false
|
||||
this.recovery = true
|
||||
setTimeout(this.start.bind(this), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
on(event, identifier, handler) {
|
||||
if (this.isConnected) {
|
||||
this.send('sub', { topic: event })
|
||||
} else {
|
||||
this.toSend.push(['sub', { topic: event }])
|
||||
}
|
||||
let filterRes = this.handlersList.filter(h => h.identifier === identifier)
|
||||
//console.log(filterRes)
|
||||
let realHandler = e => {
|
||||
//console.log(e)
|
||||
handler(e)
|
||||
}
|
||||
if (filterRes.length === 0) {
|
||||
this.handlersList.push({
|
||||
identifier,
|
||||
event,
|
||||
realHandler
|
||||
})
|
||||
} else {
|
||||
this.removeEventListener(event, filterRes[0].realHandler)
|
||||
this.handlersList = this.handlersList.map((h) => {
|
||||
if (h.identifier === identifier) {
|
||||
h.realHandler = realHandler
|
||||
}
|
||||
return h
|
||||
})
|
||||
}
|
||||
this.addEventListener(event, realHandler)
|
||||
}
|
||||
|
||||
changeAddress(address) {
|
||||
this.address = address
|
||||
window.localStorage.setItem(this.addressLocalStorageKey, this.address)
|
||||
this.start()
|
||||
}
|
||||
|
||||
getAddress() {
|
||||
return this.address
|
||||
}
|
||||
|
||||
changeIdentifier(identifier) {
|
||||
this.identifier = identifier
|
||||
window.localStorage.setItem(this.identifierLocalStorageKey, this.address)
|
||||
this.start()
|
||||
}
|
||||
|
||||
getIdentifier() {
|
||||
return this.identifier
|
||||
}
|
||||
|
||||
send(cmd, args = {}) {
|
||||
const payload = {cmd, args};
|
||||
if (!this.ws.readyState) {
|
||||
console.log("Would have sent", payload)
|
||||
return
|
||||
}
|
||||
console.debug(cmd, args)
|
||||
this.ws.send(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
}
|
||||
|
1
src/routes/+layout.js
Normal file
1
src/routes/+layout.js
Normal file
|
@ -0,0 +1 @@
|
|||
export const ssr = false;
|
5
src/routes/+layout.svelte
Normal file
5
src/routes/+layout.svelte
Normal file
|
@ -0,0 +1,5 @@
|
|||
<script>
|
||||
import "../app.css";
|
||||
</script>
|
||||
|
||||
<slot />
|
|
@ -1,2 +1,10 @@
|
|||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
||||
<script>
|
||||
import Controller from '$lib/Controller.svelte';
|
||||
import WebSocketService from '$lib/WebSocketService.js'
|
||||
|
||||
/** @type {import('./$types').PageData} */
|
||||
export let data;
|
||||
|
||||
</script>
|
||||
|
||||
<Controller />
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/kit/vite';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
adapter: adapter({
|
||||
fallback: true
|
||||
})
|
||||
},
|
||||
preprocess: vitePreprocess()
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
10
tailwind.config.cjs
Normal file
10
tailwind.config.cjs
Normal file
|
@ -0,0 +1,10 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/**/*.{html,js,svelte,ts}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
22
ws_client.py
Normal file
22
ws_client.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from websockets import connect
|
||||
|
||||
async def cmd(ws, name, **args):
|
||||
msg = {
|
||||
'cmd': name,
|
||||
'args': args
|
||||
}
|
||||
await ws.send(json.dumps(msg))
|
||||
res = await ws.recv()
|
||||
print(res)
|
||||
|
||||
async def hello(uri):
|
||||
async with connect(uri) as ws:
|
||||
await cmd(ws, "ping")
|
||||
await cmd(ws, "shit")
|
||||
await cmd(ws, "set_vector", vector_type="trans", vector_args=[1, 0])
|
||||
|
||||
asyncio.run(hello("ws://192.168.1.252:1567"))
|
Loading…
Reference in a new issue