Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,31 @@ name: Build and Test
on:
workflow_dispatch:
pull_request:
branches:
- master
push:
branches:
- master

jobs:
build:
backend-test:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r external-control-backend/requirements.txt
pip install pytest
- name: Run backend tests
working-directory: external-control-backend/tests
run: pytest

build-frontend-and-test:
runs-on: ubuntu-latest

steps:
Expand Down Expand Up @@ -40,7 +57,26 @@ jobs:
working-directory: external-control-frontend
run: npm run test

- name: Upload build artifacts
# Store build artifacts as workflow artifacts (temporary)
- name: Store build artifacts
uses: actions/upload-artifact@v4
with:
name: build-temp-artifacts
path: target/external-control-*.urcapx
if-no-files-found: error

upload-urcapx:
runs-on: ubuntu-latest
needs: [backend-test, build-frontend-and-test] # Only runs if both jobs succeed

steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-temp-artifacts
path: target/

- name: Upload final artifacts
uses: actions/upload-artifact@v4
with:
name: external-control-urcapx
Expand Down
13 changes: 8 additions & 5 deletions external-control-backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
FROM python:3.11-alpine
FROM python:3.13-alpine

# # Create necessary directories and set permissions
RUN mkdir -p /tmp && chmod 777 /tmp

# Install Flask
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# Copy the application into the image
COPY src/* .
COPY src/ ./

# Tell Flask where to load the application from
ENV FLASK_APP simple-rest-api.py
ENV FLASK_APP simple_rest_api.py

# Expose Flask's default port
EXPOSE 5000

# Run the REST service
ENTRYPOINT ["flask", "run", "--host", "0.0.0.0", "--port", "5000"]
# Run the REST service with Flask development server
ENTRYPOINT ["python", "simple_rest_api.py"]
5 changes: 2 additions & 3 deletions external-control-backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Flask Framework
Flask==2.0.1
flask-cors==3.0.10
Werkzeug==2.2.2
Flask>=2.2
flask-cors>=3.0.10
1 change: 1 addition & 0 deletions external-control-backend/src/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# This file makes the src directory a package for Python imports.
58 changes: 31 additions & 27 deletions external-control-backend/src/request_program.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import socket
import time

class RequestProgram(object):
def __init__(self, port, robotIP):
Expand Down Expand Up @@ -49,33 +50,36 @@ def send_command(self, command: str):
str: The program code received from the robot.

Raises:
Exception: If the connection to the remote PC could not be established.
Exception: If the connection to the remote PC could not be established or no data is received.
"""

program = ""
timeout = 5
# Create a socket connection with the robot IP and port number defined above
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((self.robotIP, self.port))
s.sendall(command.encode('us-ascii'))
s.settimeout(timeout) # Set timeout for receiving data

#Receive script code
raw_data = b""
while True:
try:
data = s.recv(1024)
if not data:
break

raw_data += data

except socket.timeout:
break

program = raw_data.decode("us-ascii")

# Close the connection
s.close()

return program
try:
# Create a socket connection with the robot IP and port number defined above
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.1)
s.connect((self.robotIP, self.port))
s.sendall(command.encode('us-ascii'))
# Receive script code
raw_data = b""
begin = time.time()
while True:
try:
data = s.recv(1024)
if not data:
break # Connection closed by the server
raw_data += data
except socket.timeout:
if raw_data != b"":
print("Done receiving data")
break
elif time.time() - begin > timeout:
s.close()
raise Exception(f"Connection timeout")
program = raw_data.decode("us-ascii")
s.close()
if not bool(program and program.strip()):
raise Exception(f"Did not receive any script lines")
return program
except Exception as e:
raise Exception(f"Connectivity problem with {self.robotIP}:{self.port}: {e}")
24 changes: 0 additions & 24 deletions external-control-backend/src/simple-rest-api.py

This file was deleted.

99 changes: 99 additions & 0 deletions external-control-backend/src/simple_rest_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import flask
from flask import Flask, jsonify
from flask_cors import CORS
import request_program
import socket
import time
import logging
import sys

# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
stream=sys.stdout
)
logger = logging.getLogger(__name__)

command = "request_program\n"

# Create a simple rest api with Flask (https://flask.palletsprojects.com/en/2.0.x/)
app = Flask(__name__)
CORS(app)

# Log startup message
logger.info("Starting Flask application...")
logger.info(f"Server hostname: {socket.gethostname()}")
logger.info(f"Server IP: {socket.gethostbyname(socket.gethostname())}")

# Simple in-memory cache: {(port, robotIP): (timestamp, program)}
program_cache = {}
CACHE_TTL = 2 # seconds

def split_program_sections(program_text):
preamble = ''
program_node = ''
header_start = '# HEADER_BEGIN'
header_end = '# HEADER_END'
start_idx = program_text.find(header_start)
end_idx = program_text.find(header_end)
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
end_idx += len(header_end)
preamble = program_text[start_idx:end_idx]
# Everything after header_end is the program node
program_node = program_text[end_idx:].lstrip('\n')
else:
# If no header, treat all as program_node
program_node = program_text
return preamble, program_node

def get_cached_response(cache_key, now):
if cache_key in program_cache:
ts, json_str = program_cache[cache_key]
if now - ts < CACHE_TTL:
return flask.Response(json_str, mimetype='application/json')
return None

def build_json_response(program, valid, status_text):
preamble, program_node = split_program_sections(program)
json_obj = {
"preamble": preamble,
"program_node": program_node,
"valid": valid,
"status": status_text
}
return flask.json.dumps(json_obj)

def store_in_cache(cache_key, now, json_str, valid):
if valid:
program_cache[cache_key] = (now, json_str)

@app.route('/<int:port>/<robotIP>/', methods=["GET"])
def read_params(port, robotIP):
logger.info(f"Received request for port {port} and robot IP {robotIP}")
cache_key = (port, robotIP)
now = time.time()
cached_resp = get_cached_response(cache_key, now)
if cached_resp:
logger.info(f"Returning cached response for port {port} and robot IP {robotIP}")
return cached_resp
status = None
try:
logger.info(f"Connecting to robot at {robotIP}:{port}")
con = request_program.RequestProgram(port, robotIP)
program = con.send_command(command)
valid = bool(program and program.strip())
status = "ok"
logger.info(f"Successfully retrieved program from robot at {robotIP}:{port}")
except Exception as e:
program = ''
valid = False
status = str(e)
logger.error(f"Error connecting to robot at {robotIP}:{port}: {str(e)}")
json_str = build_json_response(program, valid, status)
store_in_cache(cache_key, now, json_str, valid)
return flask.Response(json_str, mimetype='application/json')

if __name__ == '__main__':
logger.info("Flask application is ready to serve requests")
app.run(host='0.0.0.0', port=5000)
75 changes: 75 additions & 0 deletions external-control-backend/tests/test_request_program.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
import pytest
import socket
from request_program import RequestProgram

class DummySocket:
def __init__(self, responses=None, raise_timeout=False, raise_connect=False):
self.responses = responses or []
self.sent = []
self.closed = False
self.recv_calls = 0
self.raise_timeout = raise_timeout
self.raise_connect = raise_connect
self.timeout = None
def settimeout(self, timeout):
self.timeout = timeout
def connect(self, addr):
if self.raise_connect:
raise socket.error("connect error")
def sendall(self, data):
self.sent.append(data)
def recv(self, bufsize):
if self.raise_timeout:
raise socket.timeout("timed out")
if self.recv_calls < len(self.responses):
resp = self.responses[self.recv_calls]
self.recv_calls += 1
return resp
return b''
def close(self):
self.closed = True

@pytest.fixture(autouse=True)
def patch_socket(monkeypatch):
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: DummySocket())

def test_send_command_success(monkeypatch):
responses = [b'# HEADER_BEGIN\nheader\n# HEADER_END\n', b'print("hi")\n']
dummy = DummySocket(responses=responses)
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: dummy)
rp = RequestProgram(1234, '127.0.0.1')
result = rp.send_command('request_program\n')
assert '# HEADER_BEGIN\nheader\n# HEADER_END' in result
assert 'print("hi")' in result
assert dummy.closed

def test_send_command_no_data(monkeypatch):
dummy = DummySocket(responses=[])
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: dummy)
rp = RequestProgram(1234, '127.0.0.1')
with pytest.raises(Exception) as exc:
rp.send_command('request_program\n')
assert (
'Connectivity problem with 127.0.0.1:1234: Did not receive any script lines' in str(exc.value)
)
assert dummy.closed

def test_send_command_timeout(monkeypatch):
dummy = DummySocket(raise_timeout=True)
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: dummy)
rp = RequestProgram(1234, '127.0.0.1')
with pytest.raises(Exception) as exc:
rp.send_command('request_program\n')
assert 'Connectivity problem with 127.0.0.1:1234: Connection timeout' in str(exc.value)
assert dummy.closed

def test_send_command_connect_error(monkeypatch):
dummy = DummySocket(raise_connect=True)
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: dummy)
rp = RequestProgram(1234, '127.0.0.1')
with pytest.raises(Exception) as exc:
rp.send_command('request_program\n')
assert 'connect error' in str(exc.value)
assert dummy.closed or not dummy.closed # connect error may not close
Loading