Skip to content

Commit

Permalink
basic framework for starting usercode (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
shardros authored Aug 20, 2022
1 parent 361fdca commit 5d1e0f6
Show file tree
Hide file tree
Showing 6 changed files with 98 additions and 63 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ Automatically install everything and set up the virtual env:
sh install.sh
```

If the script fails because you do not have a new enough python version you can
follow the steps here:
The script is able to build python3.10 if you do not have it, alternatively you
can follow the instructions here:
- Guide: https://realpython.com/installing-python/#how-to-build-python-from-source-code
- Offical python docs: https://docs.python.org/3/using/unix.html

Expand Down Expand Up @@ -73,7 +73,7 @@ poetry run uvicorn app:app --host 0.0.0.0 --port 80

Tests are located in `test/`

Run all tests and stop on the first fail:
Run all tests and stop on the first fail with a verbose output:
```
pytest -x
pytest -xv
```
112 changes: 69 additions & 43 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import logging
import os

from pathlib import Path
import tempfile

_logger = logging.getLogger(__name__)


class Settings:

output_file_path: Path = Path("logs.txt").absolute()
log_file_path: Path = Path("logs.txt")
round_path: Path = Path("usercode/round").absolute()
round_entry_point = Path("main.py")
round_entry_path: Path = (round_path / round_entry_point).absolute()
Expand All @@ -15,67 +19,89 @@ class Settings:

round_len: float = 180.0 # seconds
reap_grace_time: float = 5.0 # seconds
arenaUSB_path: Path = Path("/media/ArenaUSB")
arena_usb_path: Path = Path("/media/ArenaUSB")

robot_path: Path = Path("/home/pi/robot").absolute()
robot_path: Path = Path("/home/pi/robot")
robot_env: dict = dict(os.environ)
on_brain: bool = False

game_control_path: Path = Path('/media/ArenaUSB')
teamname_file: Path = Path('/home/pi/teamname.txt')
robot_usb_path: Path = Path("/media/RobotUSB")
teamname_file: Path = Path("/home/pi/teamname.txt")
zone: bool = False

# tempfile.mktemp is deprecated, but there's no possibility of a race --
usr_fifo_path = tempfile.mktemp(prefix="shepherd-fifo-")

def __init__(self):
self._init_usercode()
self._on_brain()
self._init_usercode_folder()
self._zone_from_USB()

# os.mkfifo raises if its path already exists.
os.mkfifo(self.usr_fifo_path)

def _on_brain(self):
"""Detects if we are on a brain and alters the config accordingly
Looks to see if the robot_usb path exists, if it does then we are
probably on a configured brain rather than a dev PC
"""
if self.robot_usb_path.exists():
_logger.warn("Detected RobotUSB path assuming on brain")
self.log_file_path = self.robot_usb_path / self.log_file_path
config.robot_env["PYTHONPATH"] = config.robot_path
self._get_team_specifics()

def _init_usercode_folder(self):
"""Ensure that the saved usercode has a main.py and blocks.json"""
self.usr_src_path = Path("usercode/editable").absolute()
if not self.usr_src_path.exists():
os.mkdir(self.usr_src_path)

self.usr_src_main_path = self.usr_src_path / Path('main.py')
with open(self.usr_src_main_path, 'w') as main_file:
main_file.write('# DO NOT DELETE\n')
self.blocks_path = self.usr_src_path / Path('blocks.json')

if self.round_path.exists() is False:
os.mkdir(self.round_path)

def _zone_from_USB(self):
"""Set the zone based on the ARENAUSB, defaulting to zone 0"""
self.zone = "0"
for i in range(1, 4):
if (self.arena_usb_path / f"zone{i}.txt").exists():
self.zone = str(i)
return

def _get_team_specifics(self):
"""Find infomation set on each brain about the team"""
# Teamname should be set on a per brain basis before shipping
# Its purpose is to allow the setting of specific graphics for help identifing teams in the arena.
# Graphics are loaded from the ArenaUSB stick if available, or standard graphics from the stick are used.
# this used to be in rc.local, but the looks of shame and dissapointment
# got the better of me

if teamname_file.exists():
teamname_jpg = teamname_file.read_text().replace('\n', '') + '.jpg'
"""Find information set on each brain about the team
Only makes sense to run this if we are on a brain
Teamname is set per brain before shipping and allows unique graphics
for ID'ing teams in the arena.
Pick a start image in order of preference :
1) We have a team corner image on the USB
2) The team have uploaded their own image to the robot
3) We have a generic corner image on the USB
4) The game image
"""
if self.teamname_file.exists():
teamname_jpg = self.teamname_file.read_text().replace('\n', '') + '.jpg'
else:
teamname_jpg = 'none'

# Pick a start imapge in order of preference :
# 1) We have a team corner image on the USB
# 2) The team have uploaded their own image to the robot
# 3) We have a generic corner image on the USB
# 4) The game image
start_graphic = game_control_path / teamname_jpg
start_graphic = self.arena_usb_path / teamname_jpg
if not start_graphic.exists():
# attempt to find the team specific corner graphic from the ArenaUSB
start_graphic = Path('robotsrc/team_logo.jpg')
start_graphic = Path('usercode/editable/team_logo.jpg')
if not start_graphic.exists():
# attempt to find the default corner graphic from ArenaUSB
start_graphic = game_control_path / 'Corner.jpg'
start_graphic = self.arena_usb_path / 'Corner.jpg'
if not start_graphic.exists():
# finally look for a game specific logo
start_graphic = Path('/home/pi/game_logo.jpg')

if config.start_graphic.exists():
static_graphic = Path('shepherd/static/image.jpg')
static_graphic.write_bytes(start_graphic.read_bytes())

def _init_usercode(self):
"""Ensure that the saved usercode has a main.py and blocks.json"""
self.usr_src_path = Path("usercode/editable").absolute()
if not self.usr_src_path.exists():
os.mkdir(self.usr_src_path)

self.usr_src_main_path = self.usr_src_path / Path('main.py')
with open(self.usr_src_main_path, 'w') as main_file:
main_file.write('# DO NOT DELETE\n')
self.blocks_path = self.usr_src_path / Path('blocks.json')

if self.round_path.exists() is False:
os.mkdir(self.round_path)


config = Settings()

if config.on_brain is True:
config.robot_env["PYTHONPATH"] = config.robot_path
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
import uvicorn
import os

from app.routers import runner_router, upload_router, files_router
from app.run import runner
Expand Down Expand Up @@ -32,6 +33,7 @@ def startup_event():
def shutdown_event():
"""Kill any running usercode"""
runner.shutdown()
os.remove(config.usr_fifo_path)


@app.get("/")
Expand Down
35 changes: 21 additions & 14 deletions app/run.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""A wrapper on subprocess to control the state of the robot"""
import errno
import io
import json
import os
import signal
import subprocess as sp
Expand All @@ -13,7 +14,8 @@
from app.config import config


logger = logging.getLogger(__name__)
_logger = logging.getLogger(__name__)


class States(Enum):
INIT = "Init"
Expand Down Expand Up @@ -56,11 +58,12 @@ def _enter_init_state(self) -> None:
"""
# Can't just truncate the file it is possible that we don't close the
# file which can leave it in a mess to be truncated.
if config.output_file_path.exists():
os.remove(config.output_file_path)
self.output_file = open(config.output_file_path, "w+", 1)
if config.log_file_path.exists():
os.remove(config.log_file_path)
self.output_file = open(config.log_file_path, "w+", 1)
self.user_sp = sp.Popen(
[sys.executable, "-u", config.round_entry_path],
[sys.executable, "-u", config.round_entry_path,
"--startfifo", config.usr_fifo_path],
stdout=self.output_file,
stderr=sp.STDOUT,
universal_newlines=True,
Expand All @@ -70,7 +73,13 @@ def _enter_init_state(self) -> None:

def _enter_running_state(self) -> None:
"""Send start signal to usercode"""
pass # TODO:
start_settings = {
"mode": "comp",
"zone": int(config.zone),
"arena": "A",
}
with os.open(config.usr_fifo_path, os.O_WRONLY ) as usr_fifo:
json.dump(start_settings, usr_fifo)

def _enter_stopped_state(self) -> None:
"""Reap the users code"""
Expand All @@ -90,22 +99,20 @@ def _enter_stopped_state(self) -> None:
if e.errno != errno.ESRCH:
raise e
elif return_code != 0:
logger.info(
f"Usercode exited with {return_code} but was not killed by shepherd")
_logger.debug(
f"Usercode exited with {return_code} but was not killed by Shepherd")
self.output_file.close()
assert(type(self.user_sp.poll()) == int)

def _state_machine(self) -> None:
"""The lifecycle of the usercode
Don't need a try/finally as main.shutdown handles forcing into STOPPED state
"""
state_timeout = None

while True:
self.new_state_event.wait(timeout=state_timeout)
with self.state_transition_lock:
self.new_state_event.clear()
logger.info(
_logger.debug(
f"Moving state from {self._current_state} to {self._next_state}")
self._current_state = self._next_state
match self._next_state:
Expand Down Expand Up @@ -141,8 +148,8 @@ def state(self, next_state: States) -> None:

def get_output(self):
"""Open the output file in reading text mode, line buffered"""
if config.output_file_path.exists():
return open(config.output_file_path, "rt", 1).read()
if config.log_file_path.exists():
return open(config.log_file_path, "rt", 1).read()
return ""

def _run_watchdog(self) -> None:
Expand All @@ -154,7 +161,7 @@ def _run_watchdog(self) -> None:
if self._current_state == States.RUNNING: # Don't acquire lock unless we might need it
with self.state_transition_lock:
if (self._current_state == States.RUNNING) and (self.user_sp.poll() is not None):
logger.info("WATCHDOG: Detected usercode has exited")
_logger.info("WATCHDOG: Detected usercode has exited")
self._next_state = States.STOPPED
self.new_state_event.set()

Expand Down
1 change: 0 additions & 1 deletion test/convenience.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""A set of functions to make writing the tests a bit easier"""
from pprint import pprint
import time

from fastapi.testclient import TestClient
Expand Down
3 changes: 2 additions & 1 deletion test/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ def test_inital_state():
wait_until(lambda: client.get("/run/state").json() == "Ready")
assert client.get("/run/state").status_code == 200

@pytest.mark.timeout(30)
def test_start():
"""Code can be started"""
client.post("/run/start")
wait_until(lambda: client.get("/run/state").json() == "Running")
# wait_until(lambda: client.get("/run/state").json() == "Running")
response = client.get("/run/state")
assert response.status_code == 200

Expand Down

0 comments on commit 5d1e0f6

Please sign in to comment.