diff --git a/controller.py b/controller.py index aa66381..e673cc5 100755 --- a/controller.py +++ b/controller.py @@ -16,6 +16,12 @@ import logging.handlers import json import atexit +import gps +import subprocess +import threading +from ast import literal_eval + +last_active_time = 0 if (sys.version_info > (3, 0)): import importlib @@ -111,9 +117,8 @@ def write(self, config_file): os.rename(config_file, config_file+'.bak') # write out the updated config file - f = open(config_file, 'w') - f.writelines(lines) - f.close + with open(config_file, 'w') as f: + f.writelines(lines) log.info("Config file saved.") @@ -219,8 +224,8 @@ def str2bool(v): # Functions def handle_message(ws, message): + global last_active_time log.debug(message) - try: messageData = json.loads(message) except: @@ -234,15 +239,42 @@ def handle_message(ws, message): data = messageData["d"] if event == "BUTTON_COMMAND": + last_active_time = time.time() + log.debug("Received Button Command") + data['moderator'] = messageData.get('moderator', False) on_handle_command(data) # handle_command(data) + + elif event == "MUTE_MIC": + process = subprocess.Popen( + ["pactl", "set-source-mute", "@DEFAULT_SOURCE@", "1"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + stdout, stderr = process.communicate() + + log.debug("STDOUT: %s", stdout) + log.debug("STDERR: %s", stderr) + + elif event == "UNMUTE_MIC": + process = subprocess.Popen( + ["pactl", "set-source-mute", "@DEFAULT_SOURCE@", "0"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + stdout, stderr = process.communicate() + + log.debug("STDOUT: %s", stdout) + log.debug("STDERR: %s", stderr) + elif event == "MESSAGE_RECEIVED": if data['channel_id'] == networking.channel_id: - if data['type'] != "robot": + if data['type'] == 'message': on_handle_chat_message(data) elif event == "ROBOT_VALIDATED": + last_active_time = time.time() networking.handleConnectChatChannel(data["host"]) else: @@ -267,6 +299,8 @@ def handle_chat_message(args): def handle_command(args): global handlingCommand + global geofence_was_breached + global last_move_direction handlingCommand = True # catch move commands that happen before the controller has fully @@ -274,9 +308,23 @@ def handle_command(args): if move_handler == None: return + if gps_is_enabled() and not args.get('moderator', False): + # Only block and report on the first breach. After that, let commands + # through - the server decides which directions to allow/block. + if not gps.is_within_geofence() and not geofence_was_breached: + log.info('Movement blocked by geofence, last direction: %s' % last_move_direction) + networking.reportGeofenceBreach(last_move_direction) + geofence_was_breached = True + handlingCommand = False + return + log.debug('got command : %s', args) move_handler(args) + command = args.get('button', {}).get('command', '') + if command in ('f', 'b'): + last_move_direction = command + handlingCommand = False @@ -288,15 +336,28 @@ def on_handle_command(*args): thread.start_new_thread(handle_command, args) def on_handle_chat_message(*args): - if chat_module == None: - thread.start_new_thread(handle_chat_message, args) - else: - thread.start_new_thread(chat_module.handle_chat, args) + if chat_module is None: + target = handle_chat_message + else: + target = chat_module.handle_chat + + def _tts_then_finish(): + try: + target(*args) + except Exception as e: + log.error('TTS error: %s', e) + networking.declareFinishedTTS() + + t = threading.Thread(target=_tts_then_finish) + t.daemon = True + t.start() def restart_controller(command, args): if extended_command.is_authed(args['sender']) == 2: # Owner terminate.acquire() +def gps_is_enabled(): + return robot_config.getboolean('gps', 'enable_gps') # TODO : This really doesn't belong here, should probably be in start script. # watch dog timer @@ -387,13 +448,44 @@ def restart_controller(command, args): else: log.warning("Unable to find chat_custom.py") - + + + +geofence_was_breached = False +last_move_direction = '' + +if robot_config.has_section('gps') and robot_config.getboolean('gps', 'enable_gps'): + geofence_points = literal_eval(robot_config.get('gps', 'geofence_array')) + log.info("Starting GPS thread") + gps.start_gps_thread() + log.debug(geofence_points) + for point in geofence_points: + log.debug(point) + gps.set_geofence(geofence_points) +else: + log.critical('Please configure gps settings') + + atexit.register(log.debug, "Attempting to clean up and exit nicely") if not test_mode: log.critical('RemoTV Controller Started') - while not terminate.locked(): + while not terminate.locked(): # Main control loop. This handles anythign that involves timing, everything else is asynchronous time.sleep(1) watchdog.watch() + + if last_active_time + robot_config.getint('robot', 'inactivity_timeout') < time.time(): + networking.reportInactivity() + + if robot_config.getboolean('gps', 'enable_gps'): + lat, lon = gps.get_position() # Get the latest position + if lat is not None and lon is not None: + if lat == 0 and lon == 0: + log.debug("Waiting for GPS fix...") + else: + inside = gps.is_within_geofence() + if geofence_was_breached and inside: + networking.reportGeofenceClear() + geofence_was_breached = False log.critical('RemoTV Controller Exiting') else: diff --git a/controller.sample.conf b/controller.sample.conf index 41aaeec..2624dc0 100644 --- a/controller.sample.conf +++ b/controller.sample.conf @@ -23,6 +23,12 @@ type=none turn_delay=0.4 straight_delay=0.5 +inactivity_timeout=30 # Time after which, the robot reports inactivity (in seconds) + +[gps] +enable_gps = false +geofence_array = [] + [camera] # Disable video no_camera=false @@ -30,20 +36,15 @@ no_camera=false # Disable mic no_mic=false - # Specify the audio / video encoder you are using here. Currently ffmpeg, # ffmpeg-arecord and none. Note: Only robots with Raspberry Pi's or other Linux # based controllers should use ffmpeg-arecord. All others should use ffmpeg or # none. -type=ffmpeg-arecord +type=ffmpeg-ivc # X and Y resolution to capture video at -#x_res = 640 -#y_res = 480 -x_res = 768 -y_res = 432 -#x_res = 1280 -#y_res = 720 +x_res = 1280 +y_res = 720 # Video device camera_device = /dev/video0 @@ -60,7 +61,14 @@ mic_device= [ffmpeg] # Combined ffmpeg options for audio and video +# stream_output = ivs +# stream_output = obs +# stream_output = both # IVS and OBS +stream_output_destinations = ivs + +pre_ffmpeg = ffmpeg_location = /usr/bin/ffmpeg +thread_queue_size = 1024 # Windows path example #ffmpeg_location = c://ffmpeg//bin//ffmpeg.exe @@ -69,22 +77,22 @@ v4l2-ctl_location=/usr/bin/v4l2-ctl # Audio codec ffmpeg should use, only mp2 is supported but twolame will work # when compiled in to ffmpeg. -audio_codec = mp2 +audio_codec = aac # Audio channels, 1 for mono 2 for stereo. audio_channels = 1 # Bitrate for the audio stream in kilobytes -audio_bitrate = 32 +audio_bitrate = 320 # Sample rate fot the audio stream in hertz audio_sample_rate = 44100 # Video codec ffmpeg should use. Currently only mpeg1video is supported. -video_codec = mpeg1video +video_codec = h264 # Bitrate for the video stream in kilobytes -video_bitrate = 350 +video_bitrate = 1500 # Video filter options. To rotate the video 180 degree, uncomment this line #video_filter = transpose=2,transpose=2 @@ -118,7 +126,7 @@ arecord_format=S16_LE [tts] # Specify the TTS engine you are using here. Current valid types are espeak, # festival and none -type=espeak +type=none # TTS volume tts_volume=80 @@ -131,7 +139,7 @@ anon_tts=true filter_url_tts=true # Enable extended chat commands -ext_chat=true +ext_chat=False # ALSA HW number for your playback device # For Tellys this is 2. @@ -353,8 +361,8 @@ topic = LR/command # Name of the log file to be written to log_file=controller.log # log levels in order of importance are CRITICAL, ERROR, WARNING, INFO, DEBUG -file_level=WARNING -console_level=ERROR +file_level=DEBUG +console_level=DEBUG # log size is in bytes max_size=100000 # Number of old log files to hang onto. @@ -363,19 +371,27 @@ num_backup=2 # This is mostly stuff you probably shouldn't be touching [misc] # host server to connect to -server = remo.tv +server = robots.electricranch.co # API version is the version of the API. api_version = dev # video server to connect to so you can use local websocket and remote video server -video_server = remo.tv:1567 +video_format_ivs = +video_server_ivs = +video_stream_key_ivs = + +video_format_obs = +video_server_obs = +video_stream_key_obs = + + # Enable the controller to look for custom handler code -custom_hardware = True +custom_hardware = False # Enable the controller to look for custom TTS handler code -custom_tts = True +custom_tts = False # Enable the controller to look for custom chat handler code -custom_chat = True +custom_chat = False # Enable the controller to look for custom video handler code -custom_video = True +custom_video = False # Enable the watchdog timer, if you are not using a raspberry pi, you won't want # this. watchdog = True diff --git a/gps.py b/gps.py new file mode 100644 index 0000000..32240c7 --- /dev/null +++ b/gps.py @@ -0,0 +1,129 @@ +# gps.py +from __future__ import print_function +import threading +import serial +import time +import pynmea2 +import io +import logging +from shapely.geometry import Point, Polygon + +PORT = '/dev/ttyACM0' +BAUD = 9600 + +log = logging.getLogger('RemoTV.gps') + +# Global variables for latitude and longitude +current_lat = None # Coordinates are stored in decimal degrees +current_lon = None +geofence = None +geofence_limit = 0.00001 # Default geofence limit in decimal degrees roughly 1.11m or 3.6ft + +# Lock to safely access GPS data between threads +position_lock = threading.Lock() + +def open_serial(): + """ Open the serial port and return both Serial and TextIOWrapper objects """ + while True: + try: + ser = serial.Serial( + PORT, + baudrate=BAUD, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1.0, + xonxoff=False, + rtscts=False, + dsrdtr=False + ) + log.info("Connected to {}".format(PORT)) + + # Wrap the serial port to read lines of text properly + sraw = io.BufferedRWPair(ser, ser) + sio = io.TextIOWrapper(sraw) + + return ser, sio + + except serial.SerialException as e: + print("Failed to connect: {}. Retrying in 2s...".format(e)) + time.sleep(2) + +def gps_reader(): + """ Background thread to constantly read GPS data """ + global current_lat, current_lon + ser, sio = open_serial() + + while True: + try: + line = sio.readline() # This returns a full line with proper line break handling + + if line: + try: + # Parse the line using pynmea2 + msg = pynmea2.parse(line.strip()) # strip removes leading/trailing whitespaces + + if isinstance(msg, (pynmea2.types.talker.GGA, pynmea2.types.talker.RMC)): + lat = msg.latitude + lon = msg.longitude + + # Update globals safely + if lat != 0 or lon != 0: + with position_lock: + current_lat = lat + current_lon = lon + + #log.debug("Updated position: Latitude {}, Longitude {}".format(lat, lon)) + + except pynmea2.ParseError: + print("Parse error on:", repr(line)) + continue + + except serial.SerialException: + print("Serial error, reconnecting...") + try: + ser.close() + except: + pass + time.sleep(2) + ser, sio = open_serial() + +def start_gps_thread(): + """ Start the GPS reader thread """ + t = threading.Thread(target=gps_reader) + t.daemon = True + t.start() + +def get_position(): + """ Returns current GPS position (lat, lon) """ + global current_lat, current_lon + with position_lock: + return current_lat, current_lon + +def set_geofence(coords, new_geofence_limit=None): + """ Takes a series of successive coordinates and sets it as the geofence limits """ + global geofence, geofence_limit + if coords == []: + geofence = None + else: + geofence = Polygon(coords) + if new_geofence_limit: + geofence_limit = new_geofence_limit + +def is_within_geofence(): + global geofence, geofence_limit + lat, lon = get_position() + current_pos = Point(lat, lon) + if geofence is None: + return True + else: + inside = current_pos.distance(geofence) == 0 + if inside: + distance_to_edge = current_pos.distance(geofence.exterior) + else: + distance_to_edge = current_pos.distance(geofence) + log.debug("Geofence: {} - distance to edge: {}".format("inside" if inside else "outside", distance_to_edge)) + if inside and distance_to_edge > geofence_limit: + return True + else: + return False \ No newline at end of file diff --git a/networking.py b/networking.py index 0959298..94b15dc 100644 --- a/networking.py +++ b/networking.py @@ -41,7 +41,8 @@ ood = None def getChatChannels(host): - url = 'https://%s/api/%s/channels/list/%s' % (server, version, host) + url = 'http://%s/chat/%s' % (server, host) + log.debug("Attempt get chat channels at : %s", url) response = robot_util.getWithRetry(url) log.debug("getChatChannels : %s", response) return json.loads(response) @@ -75,7 +76,7 @@ def handleConnectChatChannel(host): global channel_id global chat global authenticated - + log.debug("Handle Connect Chat Channel") response = getChatChannels(host) log.debug(response) # Loop throught the return json and looked for the named channel, otherwise @@ -98,6 +99,10 @@ def handleConnectChatChannel(host): {"e": "GET_CHAT", "d": chat})) authenticated = True + # Clear any stale TTS lock from a previous session. If the robot + # disconnected mid-TTS the server may still be waiting for TTS_FINISHED. + declareFinishedTTS() + def checkWebSocket(): if not authenticated: log.critical("Websocket failed to connect or authenticate correctly") @@ -147,13 +152,14 @@ def setupWebSocket(robot_config, onHandleMessage): bootMessage = bootMessage + ". Git repo is behind by {}.".format(commits.group(0)) # log.info("using socket io to connect to control %s", controlHostPort) - log.info("configuring web socket wss://%s/" % server) - webSocket = websocket.WebSocketApp("wss://%s/" % server, + log.info("configuring web socket wss://%s/robot_connect/" % server) + webSocket = websocket.WebSocketApp("wss://%s/robot_connect/" % server, on_message=onHandleMessage, on_error=onHandleWebSocketError, on_open=onHandleWebSocketOpen, on_close=onHandleWebSocketClose) - log.info("staring websocket listen process") + log.info("starting websocket listen process") + startListenForWebSocket() schedule.single_task(5, checkWebSocket) @@ -162,6 +168,14 @@ def setupWebSocket(robot_config, onHandleMessage): #schedule a task to check internet status schedule.task(robot_config.getint('misc', 'check_freq'), internetStatus_task) +def declareFinishedTTS(): + log.info("Finishing TTS message") + webSocket.send(json.dumps( + { + "e": "TTS_FINISHED", + "d": {} + })) + def sendChatMessage(message): log.info("Sending Message : %s" % message) webSocket.send(json.dumps( @@ -173,10 +187,31 @@ def sendChatMessage(message): } })) +def reportInactivity(): + log.info("Reporting Inactivity") + webSocket.send(json.dumps( + {"e": "INACTIVITY", + "d": {} + })) + +def reportGeofenceBreach(direction): + log.info("Reporting geofence breach, direction: %s" % direction) + webSocket.send(json.dumps({ + "e": "GEOFENCE_BREACH", + "d": {"direction": direction} + })) + +def reportGeofenceClear(): + log.info("Reporting geofence clear") + webSocket.send(json.dumps({ + "e": "GEOFENCE_CLEAR", + "d": {} + })) + def isInternetConnected(): try: - url = 'https://{}'.format(server) - urllib2.urlopen(url, timeout=1) + url = 'https://{}/'.format(server) + urllib2.urlopen(url, timeout=10) log.debug("Server Detected") return True except urllib2.URLError as err: diff --git a/requirements.txt b/requirements.txt index b360638..3f1cdff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,6 @@ websocket_client==0.56.0 configparser==3.5.0 logging==0.4.9.6;python_version<"3.0" requests==2.22.0 +pynmea +pynmea2 +shapely \ No newline at end of file diff --git a/scripts/start_robot b/scripts/start_robot index 3006cf6..855fa32 100755 --- a/scripts/start_robot +++ b/scripts/start_robot @@ -2,5 +2,8 @@ # suggested use for this: # (1) use "crontab -e" to create a crontab entry: @reboot /bin/bash /home/pi/start_robot -cd /home/pi/remotv +sudo modprobe snd-dummy # Add sound dummy if no audio input is used, this is required for ffmpeg +amixer sset 'Capture' cap # Ensure audio device is set to capture + +cd /home/pi/controller nohup scripts/repeat_start python controller.py &> /dev/null & diff --git a/tts/google_cloud.py b/tts/google_cloud.py index 66c03c9..4701eb2 100644 --- a/tts/google_cloud.py +++ b/tts/google_cloud.py @@ -3,6 +3,7 @@ import logging import os import random +import subprocess import tempfile import uuid @@ -133,10 +134,20 @@ def say(*args): language_code=voiceSelection[:5] ) + log.debug("Attempting to fetch audio") response = client.synthesize_speech(synthesis_input, voice, audio_config) tempFilePath = os.path.join(tempDir, "wav_" + str(uuid.uuid4()) + ".wav") - with open(tempFilePath, 'wb') as out: - out.write(response.audio_content) - os.system('aplay ' + tempFilePath + ' -D plughw:{}'.format(hwNum)) + + try: + with open(tempFilePath, 'wb') as out: + out.write(response.audio_content) + + log.debug("Fetched audio %s" % tempFilePath) + result = subprocess.call(['/usr/bin/play', tempFilePath]) + if result != 0: + log.error("play failed with exit code %d" % result) + finally: + if os.path.exists(tempFilePath): + os.remove(tempFilePath) diff --git a/tts/tts.py b/tts/tts.py index b8593b4..3b6559f 100644 --- a/tts/tts.py +++ b/tts/tts.py @@ -108,7 +108,7 @@ def say(args): global tts_deleted if mute: - exit() + return False else: #check the number of arguments passed, and hand off as appropriate to the tts handler if not isinstance(args, dict): diff --git a/video/ffmpeg-arecord.py b/video/ffmpeg-arecord.py index 2241fc9..4e909ef 100644 --- a/video/ffmpeg-arecord.py +++ b/video/ffmpeg-arecord.py @@ -29,7 +29,7 @@ def setupArecord(robot_config): arecord_format = robot_config.get('ffmpeg-arecord', 'arecord_format') def startAudioCapture(): - audioCommandLine = ('{arecord} -D hw:{audio_hw_num} -c {audio_channels}' + audioCommandLine = ('{arecord} -c {audio_channels}' ' -f {arecord_format} -r {audio_sample_rate} |' ' {ffmpeg} -i - -ar {audio_sample_rate} -f mpegts' ' -codec:a {audio_codec} -b:a {audio_bitrate}k' diff --git a/video/ffmpeg-ivc.py b/video/ffmpeg-ivc.py new file mode 100644 index 0000000..2cbb3c6 --- /dev/null +++ b/video/ffmpeg-ivc.py @@ -0,0 +1,475 @@ +# TODO : Look at making it so the ffmpeg process toggles a boolean, that is +# used to update the server with online state appropriately. +from video.ffmpeg_process import * + +import threading +import audio_util +import networking +import watchdog +import subprocess +import shlex +import schedule +import extended_command +import atexit +import os +import sys +import logging +import robot_util +import time +import signal + +log = logging.getLogger('RemoTV.video.ffmpeg') + +robotKey=None +server_ivs=None +server_obs=None +no_mic=True +no_camera=True +mic_off=False +stream_output_destinations= None + +x_res = 640 +y_res = 480 + +ffmpeg_location = None +v4l2_ctl_location = None + +audio_hw_num = None +audio_device = None +audio_codec = None +audio_channels = None +audio_bitrate = None +video_framerate = 25 +audio_sample_rate = None +video_codec = None +video_bitrate = None +video_filter = '' +video_device = None +audio_input_format = None +audio_input_options = None +audio_output_options = None +video_input_options = None +video_output_options = None +video_start_count = 0 + +brightness=None +contrast=None +saturation=None + +old_mic_vol = 50 +mic_vol = 50 + +def setup(robot_config): + global robotKey + global no_mic + global no_camera + + global stream_key_ivs + global stream_key_obs + global server_ivs + global server_obs + global server_ivs_format + global server_obs_format + + global stream_output_destinations + + global x_res + global y_res + + global pre_ffmpeg + global ffmpeg_location + global v4l2_ctl_location + global thread_queue_size + + global audio_hw_num + global audio_device + global audio_codec + global audio_channels + global audio_bitrate + global audio_sample_rate + global video_codec + global video_bitrate + global video_framerate + global video_filter + global video_device + global audio_input_format + global audio_input_options + global audio_output_options + global video_input_format + global video_input_options + global video_output_options + + global brightness + global contrast + global saturation + + robotKey = robot_config.get('robot', 'robot_key') + + stream_output_destinations = robot_config.get('ffmpeg', 'stream_output_destinations') + + if robot_config.has_option('misc', 'video_server_ivs'): + server_ivs = robot_config.get('misc', 'video_server_ivs') + server_ivs_format = robot_config.get('misc', 'video_format_ivs') + server_obs = robot_config.get('misc', 'video_server_obs') + server_obs_format = robot_config.get('misc', 'video_format_obs') + + stream_key_ivs = robot_config.get('misc', 'video_stream_key_ivs') + stream_key_obs = robot_config.get('misc', 'video_stream_key_obs') + else: + server = robot_config.get('misc', 'server') + + no_mic = robot_config.getboolean('camera', 'no_mic') + no_camera = robot_config.getboolean('camera', 'no_camera') + + pre_ffmpeg = robot_config.get('ffmpeg', 'pre_ffmpeg') + ffmpeg_location = robot_config.get('ffmpeg', 'ffmpeg_location') + v4l2_ctl_location = robot_config.get('ffmpeg', 'v4l2-ctl_location') + + x_res = robot_config.getint('camera', 'x_res') + y_res = robot_config.getint('camera', 'y_res') + + if not no_camera: + if robot_config.has_option('camera', 'brightness'): + brightness = robot_config.get('camera', 'brightness') + if robot_config.has_option('camera', 'contrast'): + contrast = robot_config.get('camera', 'contrast') + if robot_config.has_option('camera', 'saturation'): + saturation = robot_config.get('camera', 'saturation') + + video_device = robot_config.get('camera', 'camera_device') + video_codec = robot_config.get('ffmpeg', 'video_codec') + video_bitrate = robot_config.get('ffmpeg', 'video_bitrate') + if robot_config.has_option('ffmpeg', 'video_framerate'): + video_framerate = robot_config.get('ffmpeg', 'video_framerate') + video_input_format = robot_config.get('ffmpeg', 'video_input_format') + video_input_options = robot_config.get('ffmpeg', 'video_input_options') + video_output_options = robot_config.get('ffmpeg', 'video_output_options') + + if robot_config.has_option('ffmpeg', 'video_filter'): + video_filter = robot_config.get('ffmpeg', 'video_filter') + video_filter = '-vf %s' % video_filter + + if robot_config.getboolean('tts', 'ext_chat'): + extended_command.add_command('.video', videoChatHandler) + extended_command.add_command('.brightness', brightnessChatHandler) + extended_command.add_command('.contrast', contrastChatHandler) + extended_command.add_command('.saturation', saturationChatHandler) + + if not no_mic: + if robot_config.has_option('camera', 'mic_num'): + audio_hw_num = robot_config.get('camera', 'mic_num') + else: + log.warn("controller.conf is out of date. Consider updating.") + audio_hw_num = robot_config.get('camera', 'audio_hw_num') + if robot_config.has_option('camera', 'mic_device'): + audio_device = robot_config.get('camera', 'mic_device') + else: + audio_device = robot_config.get('camera', 'audio_device') + + audio_codec = robot_config.get('ffmpeg', 'audio_codec') + audio_bitrate = robot_config.get('ffmpeg', 'audio_bitrate') + audio_sample_rate = robot_config.get('ffmpeg', 'audio_sample_rate') + audio_channels = robot_config.get('ffmpeg', 'audio_channels') + audio_input_format = robot_config.get('ffmpeg', 'audio_input_format') + audio_input_options = robot_config.get('ffmpeg', 'audio_input_options') + audio_output_options = robot_config.get('ffmpeg', 'audio_output_options') + + thread_queue_size = robot_config.get('ffmpeg', 'thread_queue_size') + + if robot_config.getboolean('tts', 'ext_chat'): + extended_command.add_command('.audio', audioChatHandler) + if audio_input_format == "alsa": + extended_command.add_command('.mic', micHandler) + + # resolve device name to hw num, only with alsa + if audio_input_format == "alsa": + if audio_device != '': + temp_hw_num = audio_util.getMicByName(audio_device.encode('utf-8')) + if temp_hw_num != None: + audio_hw_num = temp_hw_num + + # format the device for hw number if using alsa + if audio_input_format == 'alsa': + audio_device = 'hw:' + str(audio_hw_num) + +def start(): + if not no_camera: + watchdog.start("FFmpegCameraProcess", startVideoCapture) + + +# This starts the ffmpeg command string passed in command, and stores procces in the global +# variable named after the string passed in process. It registers an atexit function pass in atExit +# and uses the string passed in name as part of the error messages. +def startFFMPEG(command, name, atExit, process): + try: + if sys.platform.startswith('linux') or sys.platform == "darwin": + ffmpeg_process = subprocess.Popen( + command, + shell=True, + preexec_fn=os.setsid, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + universal_newlines=True + ) + else: + ffmpeg_process=subprocess.Popen(command, stderr=subprocess.PIPE, shell=True) + + globals()[process] = ffmpeg_process + log.info("Started FFmpeg with PID %s", ffmpeg_process.pid) + + except OSError: # Can't find / execute ffmpeg + log.critical("ERROR: Can't find / execute ffmpeg, check path in conf") + robot_util.terminate_controller() + return() + + if ffmpeg_process != None: + try: + atexit.unregister(atExit) # Only python 3 + except AttributeError: + pass + atexit.register(atExit) + + with open('./ffmpeg_log.log', 'a') as log_file: + for line in iter(ffmpeg_process.stdout.readline, ''): + log_file.write(line) + log_file.flush() + if "error" in line.lower() or "failed" in line.lower(): + log.error("FFmpeg: %s", line.strip()) + else: + log.debug("FFmpeg: %s", line.strip()) + + ffmpeg_process.wait() + log.info("FFmpeg exited with code %s", ffmpeg_process.returncode) + + + if ffmpeg_process.returncode > 0: + log.debug("ffmpeg exited abnormally with code {}".format(ffmpeg_process.returncode)) + error = ffmpeg_process.communicate() + log.debug("ffmpeg {} error message : {}".format(name, error)) + try: + log.error("ffmpeg {} : {}".format(name, error[1].decode().split('] ')[1:])) + + except IndexError: + pass + else: + log.debug("ffmpeg exited normally with code {}".format(ffmpeg_process.returncode)) + atexit.unregister(atExit) + + +def stopFFMPEG(ffmpeg_process): + try: + if sys.platform.startswith('linux') or sys.platform == "darwin": + os.killpg(os.getpgid(ffmpeg_process.pid), signal.SIGTERM) + else: + subprocess.call(['taskkill', '/F', '/T', '/PID', str(ffmpeg_process.pid)]) + + except OSError: # process is already terminated + pass + +def killStaleFFmpeg(): + """Kill any lingering ffmpeg processes and wait for device release.""" + try: + subprocess.call(['pkill', '-9', '-x', 'ffmpeg']) + log.info("Killed stale ffmpeg processes") + except OSError: + pass + + # Poll until no process holds the video device (max 10 seconds) + # fuser is part of psmisc, installed by default on Raspbian + try: + for i in range(20): + ret = subprocess.call(['fuser', '-s', video_device]) + if ret != 0: # fuser returns non-zero when no process is using it + log.info("Video device %s is free", video_device) + return + time.sleep(0.5) + log.warning("Video device %s still busy after 10s", video_device) + except OSError: + log.warning("fuser not found, falling back to fixed delay") + time.sleep(2) + +def startVideoCapture(): + global video_process + global video_start_count + + while not networking.authenticated: + time.sleep(1) + + killStaleFFmpeg() + + video_start_count += 1 + log.debug("Video start count: %s", video_start_count) + +# if video_start_count % 10 == 0: +# server refresh + + # set brightness + if (brightness is not None): + v4l2SetCtrl("brightness", brightness) + + # set contrast + if (contrast is not None): + v4l2SetCtrl("contrast", contrast) + + # set saturation + if (saturation is not None): + v4l2SetCtrl("saturation", saturation) + + log.debug("Start video command line formatting") + + videoCommandLine = '{pre_ffmpeg} {ffmpeg} -f {input_format}' + + if video_input_format != "mjpeg": + videoCommandLine += ' -video_size {xres}x{yres}' + + videoCommandLine += ( + ' {in_options}' + ' -r {framerate}' + ' -thread_queue_size {thread_queue_size}' + ' -i {video_device}' + ' -thread_queue_size {thread_queue_size}' + ' -f alsa ' + ' {audio_in_options}' + ' -ar {audio_sample_rate}' + ' -ac {audio_channels}' + ' -i plughw:{audio_hw_num}' + ' -c:v {video_codec}' + ' -b:v {video_bitrate}k' + ' -c:a {audio_codec}' + ' -b:a {audio_bitrate}k' + ' -bf 0' + ' {out_options}' + ) + if stream_output_destinations == 'both': + videoCommandLine += ( + ' -f tee' + ' "{server_ivs_format}{server_ivs}{stream_key_ivs}|' + '{server_obs_format}{server_obs}{stream_key_obs}"' + ) + elif stream_output_destinations== 'obs': + videoCommandLine += ( + ' -f mpegts' + ' "{server_obs}{stream_key_obs}"' + ) + elif stream_output_destinations== 'ivs': + videoCommandLine += ( + ' -f flv' + ' "{server_ivs}{stream_key_ivs}"' + ) + + videoCommandLine = videoCommandLine.format( + pre_ffmpeg=pre_ffmpeg, + ffmpeg=ffmpeg_location, + input_format=video_input_format, + framerate=video_framerate, + thread_queue_size=thread_queue_size, + in_options=video_input_options, + video_device=video_device, + video_codec=video_codec, + video_bitrate=video_bitrate, + out_options=video_output_options, + server_ivs_format=server_ivs_format, + server_ivs=server_ivs, + stream_key_ivs = stream_key_ivs, + server_obs_format=server_obs_format, + server_obs=server_obs, + stream_key_obs = stream_key_obs, + channel=networking.channel_id, + audio_channels = audio_channels, + audio_bitrate = audio_bitrate, + audio_sample_rate = audio_sample_rate, + audio_hw_num=audio_hw_num, + audio_in_options=audio_input_options, + audio_codec = audio_codec, + xres=x_res, + yres=y_res, + ) + + log.debug("videoCommandLine : %s", videoCommandLine) + startFFMPEG(videoCommandLine, 'Video', atExitVideoCapture, 'video_process') + +def atExitVideoCapture(): + stopFFMPEG(video_process) + +def stopVideoCapture(): + if video_process != None: + watchdog.stop('FFmpegCameraProcess') + stopFFMPEG(video_process) + +def restartVideoCapture(): + stopVideoCapture() + time.sleep(4) + watchdog.start("FFmpegCameraProcess", startVideoCapture) + +def videoChatHandler(command, args): + global video_process + global video_bitrate + + if len(command) > 1: + if extended_command.is_authed(args['sender']) == 2: # Owner + if command[1] == 'start': + if not video_process.returncode == None: + watchdog.start("FFmpegCameraProcess", startVideoCapture) + elif command[1] == 'stop': + stopVideoCapture() + elif command[1] == 'restart': + restartVideoCapture() + elif command[1] == 'bitrate': + if len(command) > 1: + if len(command) == 3: + try: + int(command[2]) + video_bitrate = command[2] + restartVideoCapture() + except ValueError: # Catch someone passing not a number + pass + networking.sendChatMessage(".Video bitrate is %s" % video_bitrate) + else: + networking.sendChatMessage("command only available to owner") + +def v4l2SetCtrl(control, level): + command = "{v4l2_ctl} -d {device} -c {control}={level}".format( + v4l2_ctl=v4l2_ctl_location, + device=video_device, + control=control, + level=str(level) + ) + os.system(command) + log.info("{control} set to {level}".format(control=control, level=level)) + + +def brightnessChatHandler(command, args): + global brightness + if len(command) > 1: + if extended_command.is_authed(args['sender']): # Moderator + try: + new_brightness = int(command[1]) + except ValueError: + exit() #Not a number + if new_brightness <= 255 and new_brightness >= 0: + brightness = new_brightness + v4l2SetCtrl("brightness", brightness) + +def contrastChatHandler(command, args): + global contrast + if len(command) > 1: + if extended_command.is_authed(args['sender']): # Moderator + try: + new_contrast = int(command[1]) + except ValueError: + exit() #not a number + if new_contrast <= 255 and new_contrast >= 0: + contrast = new_contrast + v4l2SetCtrl("contrast", contrast) + +def saturationChatHandler(command, args): + if len(command) > 2: + if extended_command.is_authed(args['sender']): # Moderator + try: + new_saturation = int(command[1]) + except ValueError: + exit() #not a number + if new_saturation <= 255 and new_saturation >= 0: + saturation = new_saturation + v4l2SetCtrl("saturation", saturation) \ No newline at end of file