[commabody] Add new body teleop ui (#29282)
	
		
	
				
					
				
			* Revert "Revert "[commabody] Add new body teleop ui (#29119)" (#29249)"
This reverts commit 623351e4ed.
* add to release files
---------
Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
			
			
				pull/214/head
			
			
		
							parent
							
								
									b34341e7e5
								
							
						
					
					
						commit
						e7d307ca1b
					
				
				 14 changed files with 1001 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; | ||||
| } | ||||
| After Width: | Height: | Size: 8.1 KiB | 
| @ -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