[commabody] Add new body teleop ui (#29119)
* add new ui
* move body tele op ui to new dir
* fix codespell errors
* resolve mediablackhole pylint error
* fix import error
* style fixes
* use logging, not print
* fix js styling
* resolve comments
old-commit-hash: a7304d059c
beeps
parent
3e9b67a514
commit
082b1fd924
13 changed files with 1003 additions and 80 deletions
@ -0,0 +1,4 @@ |
||||
av |
||||
av-10.0.0/* |
||||
key.pem |
||||
cert.pem |
@ -0,0 +1,158 @@ |
||||
import asyncio |
||||
import io |
||||
import numpy as np |
||||
import pyaudio |
||||
import wave |
||||
|
||||
from aiortc.contrib.media import MediaBlackhole |
||||
from aiortc.mediastreams import AudioStreamTrack, MediaStreamError, MediaStreamTrack |
||||
from aiortc.mediastreams import VIDEO_CLOCK_RATE, VIDEO_TIME_BASE |
||||
from aiortc.rtcrtpsender import RTCRtpSender |
||||
from av import CodecContext, Packet |
||||
from pydub import AudioSegment |
||||
import cereal.messaging as messaging |
||||
|
||||
AUDIO_RATE = 16000 |
||||
SOUNDS = { |
||||
'engage': '../../selfdrive/assets/sounds/engage.wav', |
||||
'disengage': '../../selfdrive/assets/sounds/disengage.wav', |
||||
'error': '../../selfdrive/assets/sounds/warning_immediate.wav', |
||||
} |
||||
|
||||
|
||||
def force_codec(pc, sender, forced_codec='video/VP9', stream_type="video"): |
||||
codecs = RTCRtpSender.getCapabilities(stream_type).codecs |
||||
codec = [codec for codec in codecs if codec.mimeType == forced_codec] |
||||
transceiver = next(t for t in pc.getTransceivers() if t.sender == sender) |
||||
transceiver.setCodecPreferences(codec) |
||||
|
||||
|
||||
class EncodedBodyVideo(MediaStreamTrack): |
||||
kind = "video" |
||||
|
||||
_start: float |
||||
_timestamp: int |
||||
|
||||
def __init__(self): |
||||
super().__init__() |
||||
sock_name = 'livestreamDriverEncodeData' |
||||
messaging.context = messaging.Context() |
||||
self.sock = messaging.sub_sock(sock_name, None, conflate=True) |
||||
self.pts = 0 |
||||
|
||||
async def recv(self) -> Packet: |
||||
while True: |
||||
msg = messaging.recv_one_or_none(self.sock) |
||||
if msg is not None: |
||||
break |
||||
await asyncio.sleep(0.005) |
||||
|
||||
evta = getattr(msg, msg.which()) |
||||
self.last_idx = evta.idx.encodeId |
||||
|
||||
packet = Packet(evta.header + evta.data) |
||||
packet.time_base = VIDEO_TIME_BASE |
||||
packet.pts = self.pts |
||||
self.pts += 0.05 * VIDEO_CLOCK_RATE |
||||
return packet |
||||
|
||||
|
||||
class WebClientSpeaker(MediaBlackhole): |
||||
def __init__(self): |
||||
super().__init__() |
||||
self.p = pyaudio.PyAudio() |
||||
self.buffer = io.BytesIO() |
||||
self.channels = 2 |
||||
self.stream = self.p.open(format=pyaudio.paInt16, channels=self.channels, rate=48000, frames_per_buffer=9600, output=True, stream_callback=self.pyaudio_callback) |
||||
|
||||
def pyaudio_callback(self, in_data, frame_count, time_info, status): |
||||
if self.buffer.getbuffer().nbytes < frame_count * self.channels * 2: |
||||
buff = np.zeros((frame_count, 2), dtype=np.int16).tobytes() |
||||
elif self.buffer.getbuffer().nbytes > 115200: # 3x the usual read size |
||||
self.buffer.seek(0) |
||||
buff = self.buffer.read(frame_count * self.channels * 4) |
||||
buff = buff[:frame_count * self.channels * 2] |
||||
self.buffer.seek(2) |
||||
else: |
||||
self.buffer.seek(0) |
||||
buff = self.buffer.read(frame_count * self.channels * 2) |
||||
self.buffer.seek(2) |
||||
return (buff, pyaudio.paContinue) |
||||
|
||||
async def consume(self, track): |
||||
while True: |
||||
try: |
||||
frame = await track.recv() |
||||
except MediaStreamError: |
||||
return |
||||
bio = bytes(frame.planes[0]) |
||||
self.buffer.write(bio) |
||||
|
||||
async def start(self): |
||||
for track, task in self._MediaBlackhole__tracks.items(): # pylint: disable=access-member-before-definition |
||||
if task is None: |
||||
self._MediaBlackhole__tracks[track] = asyncio.ensure_future(self.consume(track)) |
||||
|
||||
async def stop(self): |
||||
for task in self._MediaBlackhole__tracks.values(): # pylint: disable=access-member-before-definition |
||||
if task is not None: |
||||
task.cancel() |
||||
self._MediaBlackhole__tracks = {} |
||||
self.stream.stop_stream() |
||||
self.stream.close() |
||||
self.p.terminate() |
||||
|
||||
|
||||
class BodyMic(AudioStreamTrack): |
||||
def __init__(self): |
||||
super().__init__() |
||||
|
||||
self.sample_rate = AUDIO_RATE |
||||
self.AUDIO_PTIME = 0.020 # 20ms audio packetization |
||||
self.samples = int(self.AUDIO_PTIME * self.sample_rate) |
||||
self.FORMAT = pyaudio.paInt16 |
||||
self.CHANNELS = 2 |
||||
self.RATE = self.sample_rate |
||||
self.CHUNK = int(AUDIO_RATE * 0.020) |
||||
self.p = pyaudio.PyAudio() |
||||
self.mic_stream = self.p.open(format=self.FORMAT, channels=1, rate=self.RATE, input=True, frames_per_buffer=self.CHUNK) |
||||
|
||||
self.codec = CodecContext.create('pcm_s16le', 'r') |
||||
self.codec.sample_rate = self.RATE |
||||
self.codec.channels = 2 |
||||
self.audio_samples = 0 |
||||
self.chunk_number = 0 |
||||
|
||||
async def recv(self): |
||||
mic_data = self.mic_stream.read(self.CHUNK) |
||||
mic_sound = AudioSegment(mic_data, sample_width=2, channels=1, frame_rate=self.RATE) |
||||
mic_sound = AudioSegment.from_mono_audiosegments(mic_sound, mic_sound) |
||||
mic_sound += 3 # increase volume by 3db |
||||
packet = Packet(mic_sound.raw_data) |
||||
frame = self.codec.decode(packet)[0] |
||||
frame.pts = self.audio_samples |
||||
self.audio_samples += frame.samples |
||||
self.chunk_number = self.chunk_number + 1 |
||||
return frame |
||||
|
||||
|
||||
async def play_sound(sound): |
||||
chunk = 5120 |
||||
with wave.open(SOUNDS[sound], 'rb') as wf: |
||||
def callback(in_data, frame_count, time_info, status): |
||||
data = wf.readframes(frame_count) |
||||
return data, pyaudio.paContinue |
||||
|
||||
p = pyaudio.PyAudio() |
||||
stream = p.open(format=p.get_format_from_width(wf.getsampwidth()), |
||||
channels=wf.getnchannels(), |
||||
rate=wf.getframerate(), |
||||
output=True, |
||||
frames_per_buffer=chunk, |
||||
stream_callback=callback) |
||||
stream.start_stream() |
||||
while stream.is_active(): |
||||
await asyncio.sleep(0) |
||||
stream.stop_stream() |
||||
stream.close() |
||||
p.terminate() |
@ -0,0 +1,103 @@ |
||||
<html> |
||||
<head> |
||||
<meta charset="UTF-8"/> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
<title>commabody</title> |
||||
<link rel="stylesheet" href="/static/main.css"> |
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/css/bootstrap.min.css" integrity="sha512-SbiR/eusphKoMVVXysTKG/7VseWii+Y3FdHrt0EpKgpToZeemhqHeZeLWLhJutz/2ut2Vw1uQEj2MbRF+TVBUA==" crossorigin="anonymous" referrerpolicy="no-referrer" /><script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> |
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"> |
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/js/bootstrap.min.js" integrity="sha512-1/RvZTcCDEUjY/CypiMz+iqqtaoQfAITmNSJY17Myp4Ms5mdxPS5UV7iOfdZoxcGhzFbOm6sntTKJppjvuhg4g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> |
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script> |
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@^3"></script> |
||||
<script src="https://cdn.jsdelivr.net/npm/moment@^2"></script> |
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script> |
||||
</head> |
||||
<body> |
||||
<div id="main"> |
||||
<p class="jumbo">comma body</p> |
||||
<audio id="audio" autoplay="true"></audio> |
||||
<video id="video" playsinline autoplay muted loop poster="/static/poster.png"></video> |
||||
<div id="icon-panel" class="row"> |
||||
<div class="col-sm-12 col-md-6 details"> |
||||
<div class="icon-sup-panel col-12"> |
||||
<div class="icon-sub-panel"> |
||||
<div class="icon-sub-sub-panel"> |
||||
<i class="bi bi-speaker-fill pre-blob"></i> |
||||
<i class="bi bi-mic-fill pre-blob"></i> |
||||
<i class="bi bi-camera-video-fill pre-blob"></i> |
||||
</div> |
||||
<p class="small">body</p> |
||||
</div> |
||||
<div class="icon-sub-panel"> |
||||
<div class="icon-sub-sub-panel"> |
||||
<i class="bi bi-speaker-fill pre-blob"></i> |
||||
<i class="bi bi-mic-fill pre-blob"></i> |
||||
</div> |
||||
<p class="small">you</p> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="col-sm-12 col-md-6 details"> |
||||
<div class="icon-sup-panel col-12"> |
||||
<div class="icon-sub-panel"> |
||||
<div class="icon-sub-sub-panel"> |
||||
<i id="ping-time" class="pre-blob1">-</i> |
||||
</div> |
||||
<p class="bi bi-arrow-repeat small"> ping time</p> |
||||
</div> |
||||
<div class="icon-sub-panel"> |
||||
<div class="icon-sub-sub-panel"> |
||||
<i id="battery" class="pre-blob1">-</i> |
||||
</div> |
||||
<p class="bi bi-battery-half small"> battery</p> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<!-- <div class="icon-sub-panel"> |
||||
<button type="button" id="start" class="btn btn-light btn-lg">Start</button> |
||||
<button type="button" id="stop" class="btn btn-light btn-lg">Stop</button> |
||||
</div> --> |
||||
</div> |
||||
<div class="row" style="width: 100%; padding: 0px 10px 0px 10px;"> |
||||
<div id="wasd" class="col-md-12 row"> |
||||
<div class="col-md-6 col-sm-12" style="justify-content: center; display: flex; flex-direction: column;"> |
||||
<div class="wasd-row"> |
||||
<div class="keys" id="key-w">W</div> |
||||
<div id="key-val"><span id="pos-vals">0,0</span><span>x,y</span></div> |
||||
</div> |
||||
<div class="wasd-row"> |
||||
<div class="keys" id="key-a">A</div> |
||||
<div class="keys" id="key-s">S</div> |
||||
<div class="keys" id="key-d">D</div> |
||||
</div> |
||||
</div> |
||||
<div class="col-md-6 col-sm-12 form-group plan-form"> |
||||
<label for="plan-text">Plan (w, a, s, d, t)</label> |
||||
<label style="font-size: 15px;" for="plan-text">*Extremely Experimental*</label> |
||||
<textarea class="form-control" id="plan-text" rows="7" placeholder="1,0,0,0,2"></textarea> |
||||
<button type="button" id="plan-button" class="btn btn-light btn-lg">Execute</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="row" style="padding: 0px 10px 0px 10px; width: 100%;"> |
||||
<div class="panel row"> |
||||
<div class="col-sm-3" style="text-align: center;"> |
||||
<p>Play Sounds</p> |
||||
</div> |
||||
<div class="btn-group col-sm-8"> |
||||
<button type="button" id="sound-engage" class="btn btn-outline-success btn-lg sound">Engage</button> |
||||
<button type="button" id="sound-disengage" class="btn btn-outline-warning btn-lg sound">Disengage</button> |
||||
<button type="button" id="sound-error" class="btn btn-outline-danger btn-lg sound">Error</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="row" style="padding: 0px 10px 0px 10px; width: 100%;"> |
||||
<div class="panel row"> |
||||
<div class="col-sm-6"><canvas id="chart-ping"></canvas></div> |
||||
<div class="col-sm-6"><canvas id="chart-battery"></canvas></div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<script src="/static/js/jsmain.js" type="module"></script> |
||||
</body> |
||||
</html> |
@ -0,0 +1,54 @@ |
||||
const keyVals = {w: 0, a: 0, s: 0, d: 0} |
||||
|
||||
export function getXY() { |
||||
let x = -keyVals.w + keyVals.s |
||||
let y = -keyVals.d + keyVals.a |
||||
return {x, y} |
||||
} |
||||
|
||||
export const handleKeyX = (key, setValue) => { |
||||
if (['w', 'a', 's', 'd'].includes(key)){ |
||||
keyVals[key] = setValue; |
||||
let color = "#333"; |
||||
if (setValue === 1){ |
||||
color = "#e74c3c"; |
||||
} |
||||
$("#key-"+key).css('background', color); |
||||
const {x, y} = getXY(); |
||||
$("#pos-vals").text(x+","+y); |
||||
} |
||||
}; |
||||
|
||||
export async function executePlan() { |
||||
let plan = $("#plan-text").val(); |
||||
const planList = []; |
||||
plan.split("\n").forEach(function(e){ |
||||
let line = e.split(",").map(k=>parseInt(k)); |
||||
if (line.length != 5 || line.slice(0, 4).map(e=>[1, 0].includes(e)).includes(false) || line[4] < 0 || line[4] > 10){ |
||||
console.log("invalid plan"); |
||||
} |
||||
else{ |
||||
planList.push(line) |
||||
} |
||||
}); |
||||
|
||||
async function execute() { |
||||
for (var i = 0; i < planList.length; i++) { |
||||
let [w, a, s, d, t] = planList[i]; |
||||
while(t > 0){ |
||||
console.log(w, a, s, d, t); |
||||
if(w==1){$("#key-w").mousedown();} |
||||
if(a==1){$("#key-a").mousedown();} |
||||
if(s==1){$("#key-s").mousedown();} |
||||
if(d==1){$("#key-d").mousedown();} |
||||
await sleep(50); |
||||
$("#key-w").mouseup(); |
||||
$("#key-a").mouseup(); |
||||
$("#key-s").mouseup(); |
||||
$("#key-d").mouseup(); |
||||
t = t - 0.05; |
||||
} |
||||
} |
||||
} |
||||
execute(); |
||||
} |
@ -0,0 +1,23 @@ |
||||
import { handleKeyX, executePlan } from "./controls.js"; |
||||
import { start, stop, last_ping } from "./webrtc.js"; |
||||
|
||||
export var pc = null; |
||||
export var dc = null; |
||||
|
||||
document.addEventListener('keydown', (e)=>(handleKeyX(e.key.toLowerCase(), 1))); |
||||
document.addEventListener('keyup', (e)=>(handleKeyX(e.key.toLowerCase(), 0))); |
||||
$(".keys").bind("mousedown touchstart", (e)=>handleKeyX($(e.target).attr('id').replace('key-', ''), 1)); |
||||
$(".keys").bind("mouseup touchend", (e)=>handleKeyX($(e.target).attr('id').replace('key-', ''), 0)); |
||||
$("#plan-button").click(executePlan); |
||||
|
||||
setInterval( () => { |
||||
const dt = new Date().getTime(); |
||||
if ((dt - last_ping) > 1000) { |
||||
$(".pre-blob").removeClass('blob'); |
||||
$("#battery").text("-"); |
||||
$("#ping-time").text('-'); |
||||
$("video")[0].load(); |
||||
} |
||||
}, 5000); |
||||
|
||||
start(pc, dc); |
@ -0,0 +1,53 @@ |
||||
export const pingPoints = []; |
||||
export const batteryPoints = []; |
||||
|
||||
function getChartConfig(pts, color, title, ymax=100) { |
||||
return { |
||||
type: 'line', |
||||
data: { |
||||
datasets: [{ |
||||
label: title, |
||||
data: pts, |
||||
borderWidth: 1, |
||||
borderColor: color, |
||||
backgroundColor: color, |
||||
fill: 'origin' |
||||
}] |
||||
}, |
||||
options: { |
||||
scales: { |
||||
x: { |
||||
type: 'time', |
||||
time: { |
||||
unit: 'minute', |
||||
displayFormats: { |
||||
second: 'h:mm a' |
||||
} |
||||
}, |
||||
grid: { |
||||
color: '#222', // Grid lines color
|
||||
}, |
||||
ticks: { |
||||
source: 'data', |
||||
fontColor: 'rgba(255, 255, 255, 1.0)', // Y-axis label color
|
||||
} |
||||
}, |
||||
y: { |
||||
beginAtZero: true, |
||||
max: ymax, |
||||
grid: { |
||||
color: 'rgba(255, 255, 255, 0.1)', // Grid lines color
|
||||
}, |
||||
ticks: { |
||||
fontColor: 'rgba(255, 255, 255, 0.7)', // Y-axis label color
|
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
const ctxPing = document.getElementById('chart-ping'); |
||||
const ctxBattery = document.getElementById('chart-battery'); |
||||
export const chartPing = new Chart(ctxPing, getChartConfig(pingPoints, 'rgba(192, 57, 43, 0.7)', 'Controls Ping Time (ms)', 250)); |
||||
export const chartBattery = new Chart(ctxBattery, getChartConfig(batteryPoints, 'rgba(41, 128, 185, 0.7)', 'Battery %', 100)); |
@ -0,0 +1,217 @@ |
||||
import { getXY } from "./controls.js"; |
||||
import { pingPoints, batteryPoints, chartPing, chartBattery } from "./plots.js"; |
||||
|
||||
export let dcInterval = null; |
||||
export let batteryInterval = null; |
||||
export let last_ping = null; |
||||
|
||||
|
||||
export function createPeerConnection(pc) { |
||||
var config = { |
||||
sdpSemantics: 'unified-plan' |
||||
}; |
||||
|
||||
pc = new RTCPeerConnection(config); |
||||
|
||||
// connect audio / video
|
||||
pc.addEventListener('track', function(evt) { |
||||
console.log("Adding Tracks!") |
||||
if (evt.track.kind == 'video') |
||||
document.getElementById('video').srcObject = evt.streams[0]; |
||||
else |
||||
document.getElementById('audio').srcObject = evt.streams[0]; |
||||
}); |
||||
return pc; |
||||
} |
||||
|
||||
|
||||
export function negotiate(pc) { |
||||
return pc.createOffer({offerToReceiveAudio:true, offerToReceiveVideo:true}).then(function(offer) { |
||||
return pc.setLocalDescription(offer); |
||||
}).then(function() { |
||||
return new Promise(function(resolve) { |
||||
if (pc.iceGatheringState === 'complete') { |
||||
resolve(); |
||||
} |
||||
else { |
||||
function checkState() { |
||||
if (pc.iceGatheringState === 'complete') { |
||||
pc.removeEventListener('icegatheringstatechange', checkState); |
||||
resolve(); |
||||
} |
||||
} |
||||
pc.addEventListener('icegatheringstatechange', checkState); |
||||
} |
||||
}); |
||||
}).then(function() { |
||||
var offer = pc.localDescription; |
||||
return fetch('/offer', { |
||||
body: JSON.stringify({ |
||||
sdp: offer.sdp, |
||||
type: offer.type, |
||||
}), |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
}, |
||||
method: 'POST' |
||||
}); |
||||
}).then(function(response) { |
||||
console.log(response); |
||||
return response.json(); |
||||
}).then(function(answer) { |
||||
return pc.setRemoteDescription(answer); |
||||
}).catch(function(e) { |
||||
alert(e); |
||||
}); |
||||
} |
||||
|
||||
|
||||
function isMobile() { |
||||
let check = false; |
||||
(function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera); |
||||
return check; |
||||
}; |
||||
|
||||
|
||||
export const constraints = { |
||||
audio: { |
||||
autoGainControl: false, |
||||
sampleRate: 48000, |
||||
sampleSize: 16, |
||||
echoCancellation: true, |
||||
noiseSuppression: true, |
||||
channelCount: 1 |
||||
}, |
||||
video: isMobile() |
||||
}; |
||||
|
||||
|
||||
export function createDummyVideoTrack() { |
||||
const canvas = document.createElement('canvas'); |
||||
const context = canvas.getContext('2d'); |
||||
|
||||
const frameWidth = 5; // Set the width of the frame
|
||||
const frameHeight = 5; // Set the height of the frame
|
||||
canvas.width = frameWidth; |
||||
canvas.height = frameHeight; |
||||
|
||||
context.fillStyle = 'black'; |
||||
context.fillRect(0, 0, frameWidth, frameHeight); |
||||
|
||||
const stream = canvas.captureStream(); |
||||
const videoTrack = stream.getVideoTracks()[0]; |
||||
|
||||
return videoTrack; |
||||
} |
||||
|
||||
|
||||
export function start(pc, dc) { |
||||
pc = createPeerConnection(pc); |
||||
|
||||
if (constraints.audio || constraints.video) { |
||||
// add audio track
|
||||
navigator.mediaDevices.getUserMedia(constraints).then(function(stream) { |
||||
stream.getTracks().forEach(function(track) { |
||||
pc.addTrack(track, stream); |
||||
// only audio?
|
||||
// if (track.kind === 'audio'){
|
||||
// pc.addTrack(track, stream);
|
||||
// }
|
||||
}); |
||||
return negotiate(pc); |
||||
}, function(err) { |
||||
alert('Could not acquire media: ' + err); |
||||
}); |
||||
|
||||
// add a fake video?
|
||||
// const dummyVideoTrack = createDummyVideoTrack();
|
||||
// const dummyMediaStream = new MediaStream();
|
||||
// dummyMediaStream.addTrack(dummyVideoTrack);
|
||||
// pc.addTrack(dummyVideoTrack, dummyMediaStream);
|
||||
|
||||
} else { |
||||
negotiate(pc); |
||||
} |
||||
|
||||
// setInterval(() => {pc.getStats(null).then((stats) => {stats.forEach((report) => console.log(report))})}, 10000)
|
||||
// var video = document.querySelector('video');
|
||||
// var print = function (e, f){console.log(e, f); video.requestVideoFrameCallback(print);};
|
||||
// video.requestVideoFrameCallback(print);
|
||||
|
||||
|
||||
var parameters = {"ordered": true}; |
||||
dc = pc.createDataChannel('data', parameters); |
||||
dc.onclose = function() { |
||||
console.log("data channel closed"); |
||||
clearInterval(dcInterval); |
||||
clearInterval(batteryInterval); |
||||
}; |
||||
function controlCommand() { |
||||
const {x, y} = getXY(); |
||||
const dt = new Date().getTime(); |
||||
var message = JSON.stringify({type: 'control_command', x, y, dt}); |
||||
dc.send(message); |
||||
} |
||||
|
||||
function batteryLevel() { |
||||
var message = JSON.stringify({type: 'battery_level'}); |
||||
dc.send(message); |
||||
} |
||||
|
||||
dc.onopen = function() { |
||||
dcInterval = setInterval(controlCommand, 50); |
||||
batteryInterval = setInterval(batteryLevel, 10000); |
||||
controlCommand(); |
||||
batteryLevel(); |
||||
$(".sound").click((e)=>{ |
||||
const sound = $(e.target).attr('id').replace('sound-', '') |
||||
dc.send(JSON.stringify({type: 'play_sound', sound})); |
||||
}); |
||||
}; |
||||
|
||||
let val_print_idx = 0; |
||||
dc.onmessage = function(evt) { |
||||
const data = JSON.parse(evt.data); |
||||
if(val_print_idx == 0 && data.type === 'ping_time') { |
||||
const dt = new Date().getTime(); |
||||
const pingtime = dt - data.incoming_time; |
||||
pingPoints.push({'x': dt, 'y': pingtime}); |
||||
if (pingPoints.length > 1000) { |
||||
pingPoints.shift(); |
||||
} |
||||
chartPing.update(); |
||||
$("#ping-time").text((pingtime) + "ms"); |
||||
last_ping = dt; |
||||
$(".pre-blob").addClass('blob'); |
||||
} |
||||
val_print_idx = (val_print_idx + 1 ) % 20; |
||||
if(data.type === 'battery_level') { |
||||
$("#battery").text(data.value + "%"); |
||||
batteryPoints.push({'x': new Date().getTime(), 'y': data.value}); |
||||
if (batteryPoints.length > 1000) { |
||||
batteryPoints.shift(); |
||||
} |
||||
chartBattery.update(); |
||||
} |
||||
}; |
||||
} |
||||
|
||||
|
||||
export function stop(pc, dc) { |
||||
if (dc) { |
||||
dc.close(); |
||||
} |
||||
if (pc.getTransceivers) { |
||||
pc.getTransceivers().forEach(function(transceiver) { |
||||
if (transceiver.stop) { |
||||
transceiver.stop(); |
||||
} |
||||
}); |
||||
} |
||||
pc.getSenders().forEach(function(sender) { |
||||
sender.track.stop(); |
||||
}); |
||||
setTimeout(function() { |
||||
pc.close(); |
||||
}, 500); |
||||
} |
@ -0,0 +1,185 @@ |
||||
body { |
||||
background: #333 !important; |
||||
color: #fff !important; |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: start; |
||||
} |
||||
|
||||
p { |
||||
margin: 0px !important; |
||||
} |
||||
|
||||
i { |
||||
font-style: normal; |
||||
} |
||||
|
||||
.small { |
||||
font-size: 1em !important |
||||
} |
||||
|
||||
.jumbo { |
||||
font-size: 8rem; |
||||
} |
||||
|
||||
|
||||
@media (max-width: 600px) { |
||||
.small { |
||||
font-size: 0.5em !important |
||||
} |
||||
.jumbo { |
||||
display: none; |
||||
} |
||||
|
||||
} |
||||
|
||||
#main { |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-content: center; |
||||
justify-content: center; |
||||
align-items: center; |
||||
font-size: 30px; |
||||
width: 100%; |
||||
max-width: 1200px; |
||||
} |
||||
|
||||
video { |
||||
width: 95%; |
||||
} |
||||
|
||||
.pre-blob { |
||||
display: flex; |
||||
background: #333; |
||||
border-radius: 50%; |
||||
margin: 10px; |
||||
height: 45px; |
||||
width: 45px; |
||||
justify-content: center; |
||||
align-items: center; |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
.blob { |
||||
background: rgba(231, 76, 60,1.0); |
||||
box-shadow: 0 0 0 0 rgba(231, 76, 60,1.0); |
||||
animation: pulse 2s infinite; |
||||
} |
||||
|
||||
@keyframes pulse { |
||||
0% { |
||||
box-shadow: 0 0 0 0px rgba(192, 57, 43, 1); |
||||
} |
||||
100% { |
||||
box-shadow: 0 0 0 20px rgba(192, 57, 43, 0); |
||||
} |
||||
} |
||||
|
||||
|
||||
.icon-sup-panel { |
||||
display: flex; |
||||
flex-direction: row; |
||||
justify-content: space-around; |
||||
align-items: center; |
||||
background: #222; |
||||
border-radius: 10px; |
||||
padding: 5px; |
||||
margin: 5px 0px 5px 0px; |
||||
} |
||||
|
||||
.icon-sub-panel { |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
} |
||||
|
||||
#icon-panel { |
||||
display: flex; |
||||
width: 100%; |
||||
justify-content: space-between; |
||||
margin-top: 5px; |
||||
} |
||||
|
||||
.icon-sub-sub-panel { |
||||
display: flex; |
||||
flex-direction: row; |
||||
} |
||||
|
||||
.keys, #key-val { |
||||
background: #333; |
||||
padding: 2rem; |
||||
margin: 5px; |
||||
color: #fff; |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
border-radius: 10px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
#key-val { |
||||
pointer-events: none; |
||||
background: #fff; |
||||
color: #333; |
||||
line-height: 1; |
||||
font-size: 20px; |
||||
flex-direction: column; |
||||
} |
||||
|
||||
.wasd-row { |
||||
display: flex; |
||||
flex-direction: row; |
||||
justify-content: center; |
||||
align-items: stretch; |
||||
} |
||||
|
||||
#wasd { |
||||
margin: 5px 0px 5px 0px; |
||||
background: #222; |
||||
border-radius: 10px; |
||||
width: 100%; |
||||
padding: 20px; |
||||
display: flex; |
||||
flex-direction: row; |
||||
justify-content: space-around; |
||||
align-items: stretch; |
||||
|
||||
user-select: none; |
||||
-webkit-touch-callout: none; |
||||
-webkit-user-select: none; |
||||
-khtml-user-select: none; |
||||
-moz-user-select: none; |
||||
-ms-user-select: none; |
||||
touch-action: manipulation; |
||||
} |
||||
|
||||
.panel { |
||||
display: flex; |
||||
justify-content: center; |
||||
margin: 5px 0px 5px 0px !important; |
||||
background: #222; |
||||
border-radius: 10px; |
||||
width: 100%; |
||||
padding: 10px; |
||||
} |
||||
|
||||
#ping-time, #battery { |
||||
font-size: 25px; |
||||
} |
||||
|
||||
#stop { |
||||
display: none; |
||||
} |
||||
|
||||
.plan-form { |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
} |
||||
|
||||
.details { |
||||
display: flex; |
||||
padding: 0px 10px 0px 10px; |
||||
} |
@ -0,0 +1,3 @@ |
||||
version https://git-lfs.github.com/spec/v1 |
||||
oid sha256:8740da2be7faac198b5e10780c646166056a76ebbe3d64499e0cdc49280c8a4f |
||||
size 8297 |
@ -0,0 +1,201 @@ |
||||
import asyncio |
||||
import json |
||||
import logging |
||||
import os |
||||
import ssl |
||||
import uuid |
||||
import time |
||||
|
||||
from common.basedir import BASEDIR |
||||
from aiohttp import web |
||||
from aiortc import RTCPeerConnection, RTCSessionDescription |
||||
|
||||
import cereal.messaging as messaging |
||||
from tools.bodyteleop.bodyav import BodyMic, WebClientSpeaker, force_codec, play_sound, MediaBlackhole, EncodedBodyVideo |
||||
|
||||
logger = logging.getLogger("pc") |
||||
logging.basicConfig(level=logging.INFO) |
||||
|
||||
pcs = set() |
||||
pm, sm = None, None |
||||
TELEOPDIR = f"{BASEDIR}/tools/bodyteleop" |
||||
|
||||
|
||||
async def index(request): |
||||
content = open(TELEOPDIR + "/static/index.html", "r").read() |
||||
now = time.monotonic() |
||||
request.app['mutable_vals']['last_send_time'] = now |
||||
request.app['mutable_vals']['last_override_time'] = now |
||||
request.app['mutable_vals']['prev_command'] = [] |
||||
request.app['mutable_vals']['find_person'] = False |
||||
|
||||
return web.Response(content_type="text/html", text=content) |
||||
|
||||
|
||||
async def control_body(data, app): |
||||
now = time.monotonic() |
||||
if (data['type'] == 'dummy_controls') and (now < (app['mutable_vals']['last_send_time'] + 0.2)): |
||||
return |
||||
if (data['type'] == 'control_command') and (app['mutable_vals']['prev_command'] == [data['x'], data['y']] and data['x'] == 0 and data['y'] == 0): |
||||
return |
||||
|
||||
logger.info(str(data)) |
||||
x = max(-1.0, min(1.0, data['x'])) |
||||
y = max(-1.0, min(1.0, data['y'])) |
||||
dat = messaging.new_message('testJoystick') |
||||
dat.testJoystick.axes = [x, y] |
||||
dat.testJoystick.buttons = [False] |
||||
pm.send('testJoystick', dat) |
||||
app['mutable_vals']['last_send_time'] = now |
||||
if (data['type'] == 'control_command'): |
||||
app['mutable_vals']['last_override_time'] = now |
||||
app['mutable_vals']['prev_command'] = [data['x'], data['y']] |
||||
|
||||
|
||||
async def dummy_controls_msg(app): |
||||
while True: |
||||
if 'last_send_time' in app['mutable_vals']: |
||||
this_time = time.monotonic() |
||||
if (app['mutable_vals']['last_send_time'] + 0.2) < this_time: |
||||
await control_body({'type': 'dummy_controls', 'x': 0, 'y': 0}, app) |
||||
await asyncio.sleep(0.2) |
||||
|
||||
|
||||
async def start_background_tasks(app): |
||||
app['bgtask_dummy_controls_msg'] = asyncio.create_task(dummy_controls_msg(app)) |
||||
|
||||
|
||||
async def stop_background_tasks(app): |
||||
app['bgtask_dummy_controls_msg'].cancel() |
||||
await app['bgtask_dummy_controls_msg'] |
||||
|
||||
|
||||
async def offer(request): |
||||
logger.info("\n\n\nnewoffer!\n\n") |
||||
|
||||
params = await request.json() |
||||
offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"]) |
||||
speaker = WebClientSpeaker() |
||||
blackhole = MediaBlackhole() |
||||
|
||||
pc = RTCPeerConnection() |
||||
pc_id = "PeerConnection(%s)" % uuid.uuid4() |
||||
pcs.add(pc) |
||||
|
||||
def log_info(msg, *args): |
||||
logger.info(pc_id + " " + msg, *args) |
||||
|
||||
log_info("Created for %s", request.remote) |
||||
|
||||
@pc.on("datachannel") |
||||
def on_datachannel(channel): |
||||
request.app['mutable_vals']['remote_channel'] = channel |
||||
|
||||
@channel.on("message") |
||||
async def on_message(message): |
||||
data = json.loads(message) |
||||
if data['type'] == 'control_command': |
||||
await control_body(data, request.app) |
||||
times = { |
||||
'type': 'ping_time', |
||||
'incoming_time': data['dt'], |
||||
'outgoing_time': int(time.time() * 1000), |
||||
} |
||||
channel.send(json.dumps(times)) |
||||
if data['type'] == 'battery_level': |
||||
sm.update(timeout=0) |
||||
if sm.updated['carState']: |
||||
channel.send(json.dumps({'type': 'battery_level', 'value': int(sm['carState'].fuelGauge * 100)})) |
||||
if data['type'] == 'play_sound': |
||||
logger.info(f"Playing sound: {data['sound']}") |
||||
await play_sound(data['sound']) |
||||
if data['type'] == 'find_person': |
||||
request.app['mutable_vals']['find_person'] = data['value'] |
||||
|
||||
@pc.on("connectionstatechange") |
||||
async def on_connectionstatechange(): |
||||
log_info("Connection state is %s", pc.connectionState) |
||||
if pc.connectionState == "failed": |
||||
await pc.close() |
||||
pcs.discard(pc) |
||||
|
||||
@pc.on('track') |
||||
def on_track(track): |
||||
logger.info(f"Track received: {track.kind}") |
||||
if track.kind == "audio": |
||||
speaker.addTrack(track) |
||||
elif track.kind == "video": |
||||
blackhole.addTrack(track) |
||||
|
||||
@track.on("ended") |
||||
async def on_ended(): |
||||
log_info("Remote %s track ended", track.kind) |
||||
if track.kind == "audio": |
||||
await speaker.stop() |
||||
elif track.kind == "video": |
||||
await blackhole.stop() |
||||
|
||||
video_sender = pc.addTrack(EncodedBodyVideo()) |
||||
force_codec(pc, video_sender, forced_codec='video/H264') |
||||
_ = pc.addTrack(BodyMic()) |
||||
|
||||
await pc.setRemoteDescription(offer) |
||||
await speaker.start() |
||||
await blackhole.start() |
||||
answer = await pc.createAnswer() |
||||
await pc.setLocalDescription(answer) |
||||
|
||||
return web.Response( |
||||
content_type="application/json", |
||||
text=json.dumps( |
||||
{"sdp": pc.localDescription.sdp, "type": pc.localDescription.type} |
||||
), |
||||
) |
||||
|
||||
|
||||
async def on_shutdown(app): |
||||
coros = [pc.close() for pc in pcs] |
||||
await asyncio.gather(*coros) |
||||
pcs.clear() |
||||
|
||||
|
||||
async def run(cmd): |
||||
proc = await asyncio.create_subprocess_shell( |
||||
cmd, |
||||
stdout=asyncio.subprocess.PIPE, |
||||
stderr=asyncio.subprocess.PIPE |
||||
) |
||||
stdout, stderr = await proc.communicate() |
||||
logger.info("Created key and cert!") |
||||
if stdout: |
||||
logger.info(f'[stdout]\n{stdout.decode()}') |
||||
if stderr: |
||||
logger.info(f'[stderr]\n{stderr.decode()}') |
||||
|
||||
|
||||
def main(): |
||||
global pm, sm |
||||
pm = messaging.PubMaster(['testJoystick']) |
||||
sm = messaging.SubMaster(['carState', 'logMessage']) |
||||
# App needs to be HTTPS for microphone and audio autoplay to work on the browser |
||||
cert_path = TELEOPDIR + '/cert.pem' |
||||
key_path = TELEOPDIR + '/key.pem' |
||||
if (not os.path.exists(cert_path)) or (not os.path.exists(key_path)): |
||||
asyncio.run(run(f'openssl req -x509 -newkey rsa:4096 -nodes -out {cert_path} -keyout {key_path} -days 365 -subj "/C=US/ST=California/O=commaai/OU=comma body"')) |
||||
else: |
||||
logger.info("Certificate exists!") |
||||
ssl_context = ssl.SSLContext() |
||||
ssl_context.load_cert_chain(cert_path, key_path) |
||||
app = web.Application() |
||||
app['mutable_vals'] = {} |
||||
app.on_shutdown.append(on_shutdown) |
||||
app.router.add_post("/offer", offer) |
||||
app.router.add_get("/", index) |
||||
app.router.add_static('/static', TELEOPDIR + '/static') |
||||
app.on_startup.append(start_background_tasks) |
||||
app.on_cleanup.append(stop_background_tasks) |
||||
web.run_app(app, access_log=None, host="0.0.0.0", port=5000, ssl_context=ssl_context) |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
main() |
@ -1,78 +0,0 @@ |
||||
#!/usr/bin/env python3 |
||||
import time |
||||
import threading |
||||
from flask import Flask |
||||
|
||||
import cereal.messaging as messaging |
||||
|
||||
app = Flask(__name__) |
||||
pm = messaging.PubMaster(['testJoystick']) |
||||
|
||||
index = """ |
||||
<html> |
||||
<head> |
||||
<script src="https://github.com/bobboteck/JoyStick/releases/download/v1.1.6/joy.min.js"></script> |
||||
</head> |
||||
<body> |
||||
<div id="joyDiv" style="width:100%;height:100%"></div> |
||||
<script type="text/javascript"> |
||||
// Set up gamepad handlers |
||||
let gamepad = null; |
||||
window.addEventListener("gamepadconnected", function(e) { |
||||
gamepad = e.gamepad; |
||||
}); |
||||
window.addEventListener("gamepaddisconnected", function(e) { |
||||
gamepad = null; |
||||
}); |
||||
// Create JoyStick object into the DIV 'joyDiv' |
||||
var joy = new JoyStick('joyDiv'); |
||||
setInterval(function(){ |
||||
var x = -joy.GetX()/100; |
||||
var y = joy.GetY()/100; |
||||
if (x === 0 && y === 0 && gamepad !== null) { |
||||
let gamepadstate = navigator.getGamepads()[gamepad.index]; |
||||
x = -gamepadstate.axes[0]; |
||||
y = -gamepadstate.axes[1]; |
||||
} |
||||
let xhr = new XMLHttpRequest(); |
||||
xhr.open("GET", "/control/"+x+"/"+y); |
||||
xhr.send(); |
||||
}, 50); |
||||
</script> |
||||
""" |
||||
|
||||
@app.route("/") |
||||
def hello_world(): |
||||
return index |
||||
|
||||
last_send_time = time.monotonic() |
||||
@app.route("/control/<x>/<y>") |
||||
def control(x, y): |
||||
global last_send_time |
||||
x,y = float(x), float(y) |
||||
x = max(-1, min(1, x)) |
||||
y = max(-1, min(1, y)) |
||||
dat = messaging.new_message('testJoystick') |
||||
dat.testJoystick.axes = [y,x] |
||||
dat.testJoystick.buttons = [False] |
||||
pm.send('testJoystick', dat) |
||||
last_send_time = time.monotonic() |
||||
return "" |
||||
|
||||
def handle_timeout(): |
||||
while 1: |
||||
this_time = time.monotonic() |
||||
if (last_send_time+0.5) < this_time: |
||||
#print("timeout, no web in %.2f s" % (this_time-last_send_time)) |
||||
dat = messaging.new_message('testJoystick') |
||||
dat.testJoystick.axes = [0,0] |
||||
dat.testJoystick.buttons = [False] |
||||
pm.send('testJoystick', dat) |
||||
time.sleep(0.1) |
||||
|
||||
def main(): |
||||
threading.Thread(target=handle_timeout, daemon=True).start() |
||||
app.run(host="0.0.0.0") |
||||
|
||||
if __name__ == '__main__': |
||||
main() |
Loading…
Reference in new issue