Add rough draft of Python controller

This commit is contained in:
redfast00 2022-01-19 20:44:54 +01:00
parent 1dbb8c1a3f
commit f190e00d93
No known key found for this signature in database
GPG key ID: 5946E0E34FD0553C
5 changed files with 335 additions and 3 deletions

216
python/controller.py Normal file
View 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)

View file

@ -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')

View file

@ -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]

View 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>

View file

@ -0,0 +1,9 @@
function startbutton() {
fetch('/start');
}
function restartbutton() {
fetch('/restart');
}