Finish controller to log correctly, make port configurable.

This commit is contained in:
Matthew Stratford 2021-04-05 22:13:53 +01:00
parent fe80b803f8
commit 433f1ef92c
7 changed files with 163 additions and 80 deletions

View file

@ -11,6 +11,9 @@ class Controller():
self.handler() self.handler()
return return
def _state_handler(self):
return
# Registers a function for the controller class to call to tell BAPSicle to do something. # Registers a function for the controller class to call to tell BAPSicle to do something.
def register_callback(self, callback: Callable): def register_callback(self, callback: Callable):
self.callbacks.append(callback) self.callbacks.append(callback)

View file

@ -1,52 +1,105 @@
from typing import List from helpers.logging_manager import LoggingManager
from helpers.state_manager import StateManager
from typing import List, Optional
from controllers.controller import Controller from controllers.controller import Controller
from multiprocessing import Queue from multiprocessing import Queue
import serial import serial
import sys import time
from setproctitle import setproctitle from setproctitle import setproctitle
class MattchBox(Controller):
ser: serial.Serial
def __init__(self, player_to_q: List[Queue], player_from_q: List[Queue]): class MattchBox(Controller):
ser: Optional[serial.Serial]
port: Optional[str]
next_port: Optional[str]
server_state: StateManager
logger: LoggingManager
def __init__(self, server_to_q: List[Queue], server_from_q: List[Queue], state: StateManager):
process_title = "ControllerHandler" process_title = "ControllerHandler"
setproctitle(process_title) setproctitle(process_title)
self.ser = None
self.logger = LoggingManager("ControllerMattchBox")
#current_process().name = process_title #current_process().name = process_title
# connect to serial port self.server_state = state # This is a copy, will not update :/
self.ser = serial.serial_for_url("/dev/cu.usbserial-210", do_not_open=True) # This doesn't run, the callback function gets lost due to state being a copy in the multiprocessing process.
self.ser.baudrate = 2400 #self.server_state.add_callback(self._state_handler) # Allow server config changes to trigger controller reload if required.
self.port = None
self.next_port = self.server_state.state["serial_port"]
# TOOD: These need to be split in the player handler. self.server_from_q = server_from_q
self.player_from_q = player_from_q self.server_to_q = server_to_q
self.player_to_q = player_to_q
try:
self.ser.open()
except serial.SerialException as e:
sys.stderr.write('Could not open serial port {}: {}\n'.format(self.ser.name, e))
return
self.handler() self.handler()
# This doesn't run, the callback function gets lost in StateManager.
def _state_handler(self):
new_port = self.server_state.state["serial_port"]
self.logger.log.info("Got server config update. New port: {}".format(new_port))
if new_port != self.port:
self.logger.log.info("Switching from port {} to {}".format(self.port, new_port))
# The serial port config has changed. Let's reload the serial.
self.port = None
self.next_port = new_port
def connect(self, port: Optional[str]):
if port:
# connect to serial port
self.ser = serial.serial_for_url(port, do_not_open=True)
self.ser.baudrate = 2400
try:
self.ser.open()
self.logger.log.info('Connected to serial port {}'.format(port))
except serial.SerialException as e:
self.logger.log.error('Could not open serial port {}: {}'.format(port, e))
self.ser = None
else:
self.ser = None
def handler(self): def handler(self):
while True: while True:
try: if self.ser and self.ser.is_open and self.port: # If self.port is changing (via state_handler), we should stop.
if self.ser.is_open: try:
line = int.from_bytes(self.ser.read(1), "big") # Endianness doesn't matter for 1 byte. line = int.from_bytes(self.ser.read(1), "big") # Endianness doesn't matter for 1 byte.
print("Controller got:", line) self.logger.log.info("Received from controller:", line)
if (line == 255): if (line == 255):
print("Sending back KeepAlive") self.ser.write(b'\xff') # Send 255 back.
self.ser.write(b'\xff') # Send 255 back. elif (line in [1,3,5]):
elif (line in [1,3,5]): self.sendToPlayer(int(line / 2), "PLAY")
self.sendToPlayer(int(line / 2), "PLAY") elif (line in [2,4,6]):
elif (line in [2,4,6]): self.sendToPlayer(int(line / 2)-1, "STOP")
self.sendToPlayer(int(line / 2)-1, "STOP") except:
continue
finally:
time.sleep(0.01)
elif self.port:
# If there's still a port set, just wait a moment and see if it's been reconnected.
self.server_state.update("ser_connected", False)
time.sleep(10)
self.connect(self.port)
else:
# We're not already connected, or a new port connection is to be made.
if self.ser:
self.ser.close()
self.server_state.update("ser_connected", False)
if self.next_port != None:
self.connect(self.next_port)
if self.ser.is_open:
self.port = self.next_port # We connected successfully, make it stick.
self.server_state.update("ser_connected", True)
continue # skip the sleep.
time.sleep(10)
except:
continue
def sendToPlayer(self, channel: int, msg:str): def sendToPlayer(self, channel: int, msg:str):
self.player_to_q[channel].put("CONTROLLER:" + msg) self.logger.log.info("Sending message to server: " + msg)
self.server_to_q[channel].put("CONTROLLER:" + msg)

View file

@ -1,8 +1,7 @@
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
import sounddevice as sd import sounddevice as sd
from helpers.os_environment import isMacOS from helpers.os_environment import isLinux, isMacOS, isWindows
import glob
class DeviceManager(): class DeviceManager():
@classmethod @classmethod
@ -24,3 +23,35 @@ class DeviceManager():
outputs: List[Dict] = list(filter(cls._isOutput, cls._getAudioDevices())) outputs: List[Dict] = list(filter(cls._isOutput, cls._getAudioDevices()))
outputs = sorted(outputs, key=lambda k: k['name']) outputs = sorted(outputs, key=lambda k: k['name'])
return [{"name": None}] + outputs return [{"name": None}] + outputs
@classmethod
def getSerialPorts(cls) -> List[Optional[str]]:
""" Lists serial port names
:raises EnvironmentError:
On unsupported or unknown platforms
:returns:
A list of the serial ports available on the system
"""
# TODO: Get list of COM ports properly. (Can't use )
if isWindows():
ports = ['COM%s' % (i + 1) for i in range(8)]
elif isLinux():
# this excludes your current terminal "/dev/tty"
ports = glob.glob('/dev/tty[A-Za-z]*')
elif isMacOS():
ports = glob.glob('/dev/tty.*')
else:
raise EnvironmentError('Unsupported platform')
valid: List[str] = ports
result: List[Optional[str]] = []
if len(valid) > 0:
valid.sort()
result.append(None) # Add the None option
result.extend(valid)
return result

View file

@ -15,8 +15,8 @@ from helpers.types import ServerState
from typing import Any, Dict, List, NewType, Optional, Union from typing import Any, Dict, List, NewType, Optional, Union
class StateManager: class StateManager:
filepath = None filepath: str
logger = None logger: LoggingManager
callbacks: List[Any] = [] callbacks: List[Any] = []
__state = {} __state = {}
__state_in_file = {} __state_in_file = {}
@ -148,6 +148,7 @@ class StateManager:
self.logger.log.critical("Failed to execute status callback: {}".format(e)) self.logger.log.critical("Failed to execute status callback: {}".format(e))
def add_callback(self, function): def add_callback(self, function):
self._log("Adding callback: {}".format(str(function)))
self.callbacks.append(function) self.callbacks.append(function)
def _log(self, text:str, level: int = INFO): def _log(self, text:str, level: int = INFO):

View file

@ -16,7 +16,6 @@ from api_handler import APIHandler
from controllers.mattchbox_usb import MattchBox from controllers.mattchbox_usb import MattchBox
import multiprocessing import multiprocessing
import queue import queue
#import threading
import time import time
import player import player
from flask import Flask, render_template, send_from_directory, request, jsonify, abort from flask import Flask, render_template, send_from_directory, request, jsonify, abort
@ -61,8 +60,6 @@ class BAPSicleServer():
state.update("server_version", config.VERSION) state.update("server_version", config.VERSION)
startServer() startServer()
#asyncio.get_event_loop().run_until_complete(startServer())
#asyncio.get_event_loop().run_forever()
def __del__(self): def __del__(self):
stopServer() stopServer()
@ -75,18 +72,15 @@ default_state = {
"host": "localhost", "host": "localhost",
"port": 13500, "port": 13500,
"ws_port": 13501, "ws_port": 13501,
"num_channels": 3 "num_channels": 3,
"ser_port": None,
"ser_connected": False,
} }
app = Flask(__name__, static_url_path='') app = Flask(__name__, static_url_path='')
CORS(app, supports_credentials=True) # Allow ALL CORS!!!
log = logging.getLogger('werkzeug')
log.disabled = True
app.logger.disabled = True
api_from_q: queue.Queue api_from_q: queue.Queue
api_to_q: queue.Queue api_to_q: queue.Queue
@ -171,26 +165,27 @@ def server_config():
data = { data = {
"ui_page": "server", "ui_page": "server",
"ui_title": "Server Config", "ui_title": "Server Config",
"state": state.state "state": state.state,
"ser_ports": DeviceManager.getSerialPorts()
} }
return render_template("server.html", data=data) return render_template("server.html", data=data)
@app.route("/restart", methods=["POST"]) @app.route("/server/update", methods=["POST"])
async def restart_server(): def update_server():
state.update("server_name", request.form["name"]) state.update("server_name", request.form["name"])
state.update("host", request.form["host"]) state.update("host", request.form["host"])
state.update("port", int(request.form["port"])) state.update("port", int(request.form["port"]))
state.update("num_channels", int(request.form["channels"])) state.update("num_channels", int(request.form["channels"]))
state.update("ws_port", int(request.form["ws_port"])) state.update("ws_port", int(request.form["ws_port"]))
stopServer(restart=True) state.update("serial_port", request.form["serial_port"])
await startServer() #stopServer()
return server_config()
# Get audio for UI to generate waveforms. # Get audio for UI to generate waveforms.
@app.route("/audiofile/<type>/<int:id>") @app.route("/audiofile/<type>/<int:id>")
#@cross_origin()
def audio_file(type: str, id: int): def audio_file(type: str, id: int):
if type not in ["managed", "track"]: if type not in ["managed", "track"]:
abort(404) abort(404)
@ -334,10 +329,6 @@ def list_showplans():
response = api_from_q.get_nowait() response = api_from_q.get_nowait()
if response.startswith("LIST_PLANS:"): if response.startswith("LIST_PLANS:"):
response = response[response.index(":")+1:] response = response[response.index(":")+1:]
#try:
# response = json.loads(response)
#except Exception as e:
# raise e
return response return response
except queue.Empty: except queue.Empty:
@ -539,11 +530,12 @@ def startServer():
player_handler = multiprocessing.Process(target=PlayerHandler, args=(channel_from_q, websocket_to_q, ui_to_q, controller_to_q)) player_handler = multiprocessing.Process(target=PlayerHandler, args=(channel_from_q, websocket_to_q, ui_to_q, controller_to_q))
player_handler.start() player_handler.start()
# Note, state here will become a copy in the process.
# It will not update, and callbacks will not work :/
websockets_server = multiprocessing.Process(target=WebsocketServer, args=(channel_to_q, websocket_to_q, state)) websockets_server = multiprocessing.Process(target=WebsocketServer, args=(channel_to_q, websocket_to_q, state))
websockets_server.start() websockets_server.start()
controller_handler = multiprocessing.Process(target=MattchBox, args=(channel_to_q, controller_to_q, state))
controller_handler = multiprocessing.Process(target=MattchBox, args=(channel_to_q, controller_to_q))
controller_handler.start() controller_handler.start()
# TODO Move this to player or installer. # TODO Move this to player or installer.
@ -579,19 +571,33 @@ def startServer():
channel_to_q[0].put("PLAY") channel_to_q[0].put("PLAY")
# Don't use reloader, it causes Nested Processes! # Don't use reloader, it causes Nested Processes!
def runWebServer():
CORS(app, supports_credentials=True) # Allow ALL CORS!!!
log = logging.getLogger('werkzeug')
log.disabled = True
app.logger.disabled = True
app.run(host=state.state["host"], port=state.state["port"], debug=True, use_reloader=False)
global webserver global webserver
webserver = multiprocessing.Process( webserver = multiprocessing.Process(
app.run(host=state.state["host"], port=state.state["port"], debug=True, use_reloader=False) runWebServer()
) )
webserver.start() webserver.start()
def stopServer(restart=False): def stopServer():
global channel_p, channel_from_q, channel_to_q, websockets_server, webserver global channel_p, channel_from_q, channel_to_q, websockets_server, webserver, controller_handler
print("Stopping Controllers")
controller_handler.terminate()
controller_handler.join()
print("Stopping Websockets") print("Stopping Websockets")
websocket_to_q[0].put("WEBSOCKET:QUIT") websocket_to_q[0].put("WEBSOCKET:QUIT")
websockets_server.join() websockets_server.join()
del websockets_server del websockets_server
print("Stopping server.py") print("Stopping server.py")
for q in channel_to_q: for q in channel_to_q:
q.put("QUIT") q.put("QUIT")
@ -607,21 +613,13 @@ def stopServer(restart=False):
del channel_to_q del channel_to_q
print("Stopped all players.") print("Stopped all players.")
print("Stopping webserver")
global webserver global webserver
webserver.terminate() webserver.terminate()
webserver.join() webserver.join()
return
## Caused an outside context error, presuably because called outside of a page request. print("Stopped webserver")
#shutdown = request.environ.get('werkzeug.server.shutdown')
#if shutdown is None:
# print("Shutting down Server.")
#else:
# print("Shutting down Flask.")
# if not restart:
# shutdown()
if __name__ == "__main__": if __name__ == "__main__":
print("BAPSicle is a service. Please run it like one.") raise Exception("BAPSicle is a service. Please run it like one.")

View file

@ -7,7 +7,7 @@
Set for: Set for:
{% for channel in data.channels %} {% for channel in data.channels %}
<a href="/player/{{channel.channel}}/output/none">Channel {{channel.channel}}</a> <a href="/player/{{channel.channel}}/output/{{output.name}}">Channel {{channel.channel}}</a>
{% endfor %} {% endfor %}
- {% if output.name %}{{output.name}}{% else %}System Default Output{% endif %}<br> - {% if output.name %}{{output.name}}{% else %}System Default Output{% endif %}<br>
{% endfor %} {% endfor %}

View file

@ -1,7 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content_inner %} {% block content_inner %}
{% if data %} {% if data %}
<form action="/restart" method="POST"> <form action="/server/update" method="POST">
<label for="host">Server Host:</label> <label for="host">Server Host:</label>
<input type="text" id="host" name="host" class="form-control" value="{{data.state.host}}"> <input type="text" id="host" name="host" class="form-control" value="{{data.state.host}}">
<br> <br>
@ -17,15 +17,12 @@
<label for="channels">Number of Channels:</label> <label for="channels">Number of Channels:</label>
<input type="number" id="channels" name="channels" class="form-control" value="{{data.state.num_channels}}"> <input type="number" id="channels" name="channels" class="form-control" value="{{data.state.num_channels}}">
<br> <br>
<label for="ser_port">BAPS Controller Serial Port:</label> <label for="serial_port">BAPS Controller Serial Port:</label>
<select class="form-control" name="ser_port"> <select class="form-control" name="serial_port">
<label>Serial Ports</label> <label>Serial Ports</label>
{% for port in data.ser_ports %} {% for port in data.ser_ports %}
<option value="{{port.name}}">{{port.name}}</option> <option value="{{port}}" {% if port == data.state.serial_port %}selected{% endif %}>{{port}}</option>
{% endfor %} {% endfor %}
<option value="none">{% if not data.ser_ports %}No ports found.{% else %}None{% endif %}</option>
</select> </select>
<br> <br>
<input type="submit" class="btn btn-primary" value="Save & Restart Server"> <input type="submit" class="btn btn-primary" value="Save & Restart Server">