Add rough draft of Python controller
This commit is contained in:
parent
1dbb8c1a3f
commit
f190e00d93
5 changed files with 335 additions and 3 deletions
216
python/controller.py
Normal file
216
python/controller.py
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
from threading import Thread
|
||||||
|
from flask import Flask, jsonify, send_file
|
||||||
|
from time import sleep
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import serial
|
||||||
|
import uuid
|
||||||
|
from collections import deque
|
||||||
|
import sys
|
||||||
|
import enum
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
from obus import Message, ModuleAddress
|
||||||
|
|
||||||
|
|
||||||
|
INFO_ROUND_DURATION = timedelta(seconds=3)
|
||||||
|
GAMESTATE_UPDATE_INTERVAL = timedelta(seconds=0.5)
|
||||||
|
|
||||||
|
|
||||||
|
@enum.unique
|
||||||
|
class Gamestate(enum.Enum):
|
||||||
|
INACTIVE = 0
|
||||||
|
INFO = 1
|
||||||
|
DISCOVER = 2
|
||||||
|
GAME = 3
|
||||||
|
GAMEOVER = 4
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PuzzleState:
|
||||||
|
'''State keeping object for puzzle and needy modules'''
|
||||||
|
strike_amount: int = 0
|
||||||
|
solved: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SharedWebToSerial:
|
||||||
|
game_duration: timedelta = timedelta(seconds=60)
|
||||||
|
max_allowed_strikes: int = 3
|
||||||
|
seed: int = 0
|
||||||
|
blocked_modules: list[ModuleAddress] = field(default_factory=list)
|
||||||
|
start_game: bool = False
|
||||||
|
restart_game: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SharedSerialToWeb:
|
||||||
|
gamestate: Gamestate = Gamestate.INACTIVE
|
||||||
|
info_round_start: datetime = None
|
||||||
|
discover_round_start: datetime = None
|
||||||
|
game_start: datetime = None
|
||||||
|
game_stop: datetime = None
|
||||||
|
last_state_update: datetime = None
|
||||||
|
registered_modules: dict[ModuleAddress, PuzzleState] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
server_id = uuid.uuid4()
|
||||||
|
print("Server ID: ", server_id)
|
||||||
|
|
||||||
|
web_to_serial = SharedWebToSerial()
|
||||||
|
serial_to_web = SharedSerialToWeb()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_can_line(ser) -> Message:
|
||||||
|
if not ser.in_waiting:
|
||||||
|
return None
|
||||||
|
line = ser.read(12)
|
||||||
|
if len(line) == 12:
|
||||||
|
if line == b'BEGIN START\n' or line[0] > 0b111:
|
||||||
|
return None
|
||||||
|
sender = (int(line[0]) << 8) + int(line[1])
|
||||||
|
size = int(line[2])
|
||||||
|
message = line[3:3+size]
|
||||||
|
return Message(message, sender, datetime.now())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def send_message(ser, msg) -> None:
|
||||||
|
# we send the payload padded with null-bytes, but these don't actually get sent
|
||||||
|
packed = struct.pack('>HB8s', msg.module_address().as_binary(), len(msg.payload), msg.payload)
|
||||||
|
ser.write(packed + b'\n')
|
||||||
|
|
||||||
|
def calculate_puzzle_modules_left(serial_to_web) -> int:
|
||||||
|
return sum(address.is_puzzle() and not state.solved for address, state in serial_to_web.registered_modules.items())
|
||||||
|
|
||||||
|
def calculate_strikes(serial_to_web) -> int:
|
||||||
|
return sum(state.strike_amount for state in serial_to_web.registered_modules.values())
|
||||||
|
|
||||||
|
def serial_controller(serialport, web_to_serial, serial_to_web):
|
||||||
|
with serial.Serial(serialport, 115200, timeout=0.05) as ser:
|
||||||
|
serial_to_web.gamestate = Gamestate.INACTIVE
|
||||||
|
# TODO send message here to get all modules to stop talking and reset
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
time.sleep(5)
|
||||||
|
while True:
|
||||||
|
if serial_to_web.gamestate == Gamestate.INACTIVE:
|
||||||
|
send_message(ser, Message.create_controller_infostart(web_to_serial.seed))
|
||||||
|
serial_to_web.gamestate = Gamestate.INFO
|
||||||
|
serial_to_web.info_round_start = datetime.now()
|
||||||
|
serial_to_web.registered_modules = {}
|
||||||
|
elif serial_to_web.gamestate == Gamestate.INFO:
|
||||||
|
parse_can_line(ser) # throw away, TODO keep this and display it
|
||||||
|
if datetime.now() - serial_to_web.info_round_start > INFO_ROUND_DURATION:
|
||||||
|
serial_to_web.gamestate = Gamestate.DISCOVER
|
||||||
|
send_message(ser, Message.create_controller_hello())
|
||||||
|
elif serial_to_web.gamestate == Gamestate.DISCOVER:
|
||||||
|
if web_to_serial.start_game:
|
||||||
|
web_to_serial.start_game = False
|
||||||
|
serial_to_web.game_start = datetime.now()
|
||||||
|
serial_to_web.last_state_update = datetime.now()
|
||||||
|
serial_to_web.gamestate = Gamestate.GAME
|
||||||
|
send_message(ser, Message.create_controller_gamestart(web_to_serial.game_duration, 0, web_to_serial.max_allowed_strikes, len(serial_to_web.registered_modules)))
|
||||||
|
msg = parse_can_line(ser)
|
||||||
|
if msg is None:
|
||||||
|
continue
|
||||||
|
puzzle_address = msg.get_puzzle_register()
|
||||||
|
if puzzle_address is None:
|
||||||
|
continue
|
||||||
|
if puzzle_address in web_to_serial.blocked_modules:
|
||||||
|
# this is blocked puzzle module, don't ack it
|
||||||
|
continue
|
||||||
|
serial_to_web.registered_modules[puzzle_address] = PuzzleState()
|
||||||
|
send_message(ser, Message.create_controller_ack(msg.module_address()))
|
||||||
|
|
||||||
|
elif serial_to_web.gamestate == Gamestate.GAME:
|
||||||
|
# React to puzzle strike / solve
|
||||||
|
msg = parse_can_line(ser)
|
||||||
|
if msg is None:
|
||||||
|
pass
|
||||||
|
elif (strike_details := msg.get_puzzle_strike_details()):
|
||||||
|
strike_address, strike_amount = strike_details
|
||||||
|
serial_to_web.registered_modules[strike_address].strike_amount = strike_amount
|
||||||
|
elif (solved_puzzle_address := msg.get_puzzle_solved()):
|
||||||
|
serial_to_web.registered_modules[solved_puzzle_address].solved = True
|
||||||
|
|
||||||
|
# Handle strikeout / timeout / solve
|
||||||
|
time_left = web_to_serial.game_duration - (datetime.now() - serial_to_web.game_start)
|
||||||
|
puzzle_modules_left = calculate_puzzle_modules_left(serial_to_web)
|
||||||
|
total_strikes = calculate_strikes(serial_to_web)
|
||||||
|
if time_left.total_seconds() <= 0:
|
||||||
|
# Pass zero timedelta, because time left can't be negative in the CAN protocol
|
||||||
|
# Timeout case is also handled first, so that in other cases we know there's time left
|
||||||
|
send_message(ser, Message.create_controller_timeout(timedelta(), puzzle_modules_left, web_to_serial.max_allowed_strikes, puzzle_modules_left))
|
||||||
|
serial_to_web.gamestate = Gamestate.GAMEOVER
|
||||||
|
elif total_strikes > web_to_serial.max_allowed_strikes:
|
||||||
|
send_message(ser, Message.create_controller_strikeout(time_left, puzzle_modules_left, web_to_serial.max_allowed_strikes, puzzle_modules_left))
|
||||||
|
serial_to_web.gamestate = Gamestate.GAMEOVER
|
||||||
|
elif puzzle_modules_left == 0:
|
||||||
|
send_message(ser, Message.create_controller_solved(time_left, puzzle_modules_left, web_to_serial.max_allowed_strikes, puzzle_modules_left))
|
||||||
|
serial_to_web.gamestate = Gamestate.GAMEOVER
|
||||||
|
if serial_to_web.gamestate == Gamestate.GAMEOVER:
|
||||||
|
serial_to_web.game_stop = datetime.now()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if datetime.now() - serial_to_web.last_state_update > GAMESTATE_UPDATE_INTERVAL:
|
||||||
|
serial_to_web.last_state_update = datetime.now()
|
||||||
|
# Send state update with known-good checked values
|
||||||
|
send_message(ser, Message.create_controller_state(time_left, total_strikes, web_to_serial.max_allowed_strikes, puzzle_modules_left))
|
||||||
|
|
||||||
|
elif serial_to_web.gamestate == Gamestate.GAMEOVER:
|
||||||
|
if web_to_serial.restart_game:
|
||||||
|
web_to_serial.restart_game = False
|
||||||
|
serial_to_web.gamestate = Gamestate.INACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/status.json')
|
||||||
|
def status():
|
||||||
|
status_dict = {
|
||||||
|
'gamestate': serial_to_web.gamestate.name
|
||||||
|
}
|
||||||
|
if serial_to_web.gamestate == Gamestate.GAME:
|
||||||
|
# Send the time left to avoid time syncronisation issues between server and client
|
||||||
|
# Client can then extrapolate if it wants to
|
||||||
|
status_dict['timeleft'] = (datetime.now() - serial_to_web.game_start).total_seconds()
|
||||||
|
elif serial_to_web.gamestate == Gamestate.GAMEOVER:
|
||||||
|
status_dict['timeleft'] = (serial_to_web.game_stop - serial_to_web.game_start).total_seconds()
|
||||||
|
|
||||||
|
if serial_to_web.gamestate in (Gamestate.DISCOVER, Gamestate.GAME, Gamestate.GAMEOVER):
|
||||||
|
status_dict['puzzles'] = [
|
||||||
|
{'address': address.to_binary(), 'solved': state.solved if address.is_puzzle() else None, 'strikes': state.strike_amount}
|
||||||
|
for address, state
|
||||||
|
in serial_to_web.registered_modules.items()
|
||||||
|
]
|
||||||
|
return jsonify(status_dict)
|
||||||
|
|
||||||
|
@app.route('/start')
|
||||||
|
def start():
|
||||||
|
if serial_to_web.gamestate == Gamestate.DISCOVER:
|
||||||
|
web_to_serial.start_game = True
|
||||||
|
return 'OK'
|
||||||
|
return 'Wrong gamestage'
|
||||||
|
|
||||||
|
@app.route('/restart')
|
||||||
|
def restart():
|
||||||
|
if serial_to_web.gamestate == Gamestate.GAMEOVER:
|
||||||
|
web_to_serial.restart_game = True
|
||||||
|
return 'OK'
|
||||||
|
return 'Wrong gamestage'
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return send_file('static/controller.html')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python3 controller.py [serial port]")
|
||||||
|
sys.exit()
|
||||||
|
thread = Thread(target=serial_controller, args=(sys.argv[1], web_to_serial, serial_to_web))
|
||||||
|
thread.start()
|
||||||
|
app.run(debug=False, host='0.0.0.0', port=8080)
|
|
@ -24,8 +24,8 @@ max_message_cache = 200
|
||||||
shared_data = SharedData(deque(maxlen=max_message_cache), -1)
|
shared_data = SharedData(deque(maxlen=max_message_cache), -1)
|
||||||
|
|
||||||
|
|
||||||
def serial_reader(shared_data):
|
def serial_reader(serialport, shared_data):
|
||||||
with serial.Serial('/dev/ttyUSB0', 115200, timeout=0.05) as ser:
|
with serial.Serial(serialport, 115200, timeout=0.05) as ser:
|
||||||
while True:
|
while True:
|
||||||
line = ser.read(12)
|
line = ser.read(12)
|
||||||
if not line:
|
if not line:
|
||||||
|
@ -55,6 +55,9 @@ def api(last_received):
|
||||||
return jsonify({"server_id": server_id, "newest_msg": shared_data.last_message_index, "messages": list(shared_data.messages)[len(shared_data.messages) - (shared_data.last_message_index - last_received):]})
|
return jsonify({"server_id": server_id, "newest_msg": shared_data.last_message_index, "messages": list(shared_data.messages)[len(shared_data.messages) - (shared_data.last_message_index - last_received):]})
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
thread = Thread(target=serial_reader, args=(shared_data, ))
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python3 debugserver.py [serial port]")
|
||||||
|
sys.exit()
|
||||||
|
thread = Thread(target=serial_reader, args=(sys.argv[1], shared_data, ))
|
||||||
thread.start()
|
thread.start()
|
||||||
app.run(debug=False, host='0.0.0.0')
|
app.run(debug=False, host='0.0.0.0')
|
||||||
|
|
|
@ -1,5 +1,31 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import struct
|
||||||
|
import enum
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
|
||||||
|
@enum.unique
|
||||||
|
class ControllerMessageType(enum.Enum):
|
||||||
|
ACK = 0
|
||||||
|
HELLO = 1
|
||||||
|
GAMESTART = 2
|
||||||
|
STATE = 3
|
||||||
|
SOLVED = 4
|
||||||
|
TIMEOUT = 5
|
||||||
|
STRIKEOUT = 6
|
||||||
|
INFOSTART = 7
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleAddress(NamedTuple):
|
||||||
|
module_type: int
|
||||||
|
identifier: int
|
||||||
|
|
||||||
|
def is_puzzle(self):
|
||||||
|
return self.module_type == 1
|
||||||
|
|
||||||
|
def as_binary(self):
|
||||||
|
return (self.module_type << 8) | self.identifier
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -8,6 +34,67 @@ class Message:
|
||||||
received_from: int
|
received_from: int
|
||||||
received_at: datetime
|
received_at: datetime
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_message(cls, controller_message_type, content):
|
||||||
|
return cls(bytes([controller_message_type.value]) + content, 0, datetime.now())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_infostart(cls, seed):
|
||||||
|
assert 0 <= seed <= 0xFFFFFFFF
|
||||||
|
return cls.create_controller_message(ControllerMessageType.INFOSTART, struct.pack('>L', seed))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_ack(cls, module_address):
|
||||||
|
return cls.create_controller_message(ControllerMessageType.ACK, struct.pack('>H', module_address.as_binary()))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_hello(cls):
|
||||||
|
return cls.create_controller_message(ControllerMessageType.HELLO, b'')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_generic_stateupdate(cls, controller_message_type, timeleft, current_strikes, max_strikes, amount_puzzle_modules_left):
|
||||||
|
time_left_ms = int(timeleft.total_seconds() * 1000)
|
||||||
|
assert 0 <= time_left_ms <= 0xFFFFFFFF
|
||||||
|
return cls.create_controller_message(controller_message_type, struct.pack('>LBBB', time_left_ms, current_strikes, max_strikes, amount_puzzle_modules_left))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_gamestart(cls, *args):
|
||||||
|
return cls.create_controller_generic_stateupdate(ControllerMessageType.GAMESTART, *args)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_state(cls, *args):
|
||||||
|
return cls.create_controller_generic_stateupdate(ControllerMessageType.STATE, *args)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_solved(cls, *args):
|
||||||
|
return cls.create_controller_generic_stateupdate(ControllerMessageType.SOLVED, *args)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_timeout(cls, *args):
|
||||||
|
return cls.create_controller_generic_stateupdate(ControllerMessageType.TIMEOUT, *args)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_controller_strikeout(cls, *args):
|
||||||
|
return cls.create_controller_generic_stateupdate(ControllerMessageType.STRIKEOUT, *args)
|
||||||
|
|
||||||
|
def get_puzzle_register(self):
|
||||||
|
'''Returns the address of the puzzle if this is a register puzzle message, None otherwise'''
|
||||||
|
if self.sender_type() == 1 and self.payload[0] == 0:
|
||||||
|
return self.module_address()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_puzzle_strike_details(self):
|
||||||
|
'''Returns the address and amount of strikes of the puzzle if this is a puzzle strike message, None otherwise'''
|
||||||
|
if self.sender_type() == 1 and self.payload[0] == 1:
|
||||||
|
return (self.module_address(), self.payload[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_puzzle_solved(self):
|
||||||
|
'''Returns the address of the puzzle if this is a solved puzzle message, None otherwise'''
|
||||||
|
if self.sender_type() == 1 and self.payload[0] == 2:
|
||||||
|
return self.module_address()
|
||||||
|
return None
|
||||||
|
|
||||||
def readable_time(self):
|
def readable_time(self):
|
||||||
return self.received_at.strftime('%H:%M:%S')
|
return self.received_at.strftime('%H:%M:%S')
|
||||||
|
|
||||||
|
@ -20,6 +107,9 @@ class Message:
|
||||||
def sender_id(self):
|
def sender_id(self):
|
||||||
return (self.received_from >> 0) & 0b1111_1111
|
return (self.received_from >> 0) & 0b1111_1111
|
||||||
|
|
||||||
|
def module_address(self):
|
||||||
|
return ModuleAddress(self.sender_type(), self.sender_id())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def human_readable_type(sender_type, sender_id):
|
def human_readable_type(sender_type, sender_id):
|
||||||
return [('controller' if sender_id == 0 else 'info'), 'puzzle', 'needy', 'RESERVED TYPE'][sender_type]
|
return [('controller' if sender_id == 0 else 'info'), 'puzzle', 'needy', 'RESERVED TYPE'][sender_type]
|
||||||
|
|
14
python/static/controller.html
Normal file
14
python/static/controller.html
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OBUS controller</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<button onclick="startbutton()">START</button>
|
||||||
|
<button onclick="restartbutton()">RESTART</button>
|
||||||
|
<script src="static/controller.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
9
python/static/controller.js
Normal file
9
python/static/controller.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
|
||||||
|
|
||||||
|
function startbutton() {
|
||||||
|
fetch('/start');
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartbutton() {
|
||||||
|
fetch('/restart');
|
||||||
|
}
|
Loading…
Reference in a new issue