[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