Skip to content

Commit fed14d7

Browse files
urrskurfeex
andauthored
Add Multinode support (#17)
Bump to version 1.0.0 as it is now feature comparable with its Polyscope 5 sister Co-authored-by: Felix Exner <[email protected]>
1 parent 9cc7a0c commit fed14d7

19 files changed

+484
-88
lines changed

.github/workflows/build-and-test.yml

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,31 @@ name: Build and Test
33
on:
44
workflow_dispatch:
55
pull_request:
6-
branches:
7-
- master
86
push:
97
branches:
108
- master
119

1210
jobs:
13-
build:
11+
backend-test:
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
- name: Set up Python
18+
uses: actions/setup-python@v4
19+
with:
20+
python-version: '3.13'
21+
- name: Install dependencies
22+
run: |
23+
python -m pip install --upgrade pip
24+
pip install -r external-control-backend/requirements.txt
25+
pip install pytest
26+
- name: Run backend tests
27+
working-directory: external-control-backend/tests
28+
run: pytest
29+
30+
build-frontend-and-test:
1431
runs-on: ubuntu-latest
1532

1633
steps:
@@ -40,7 +57,26 @@ jobs:
4057
working-directory: external-control-frontend
4158
run: npm run test
4259

43-
- name: Upload build artifacts
60+
# Store build artifacts as workflow artifacts (temporary)
61+
- name: Store build artifacts
62+
uses: actions/upload-artifact@v4
63+
with:
64+
name: build-temp-artifacts
65+
path: target/external-control-*.urcapx
66+
if-no-files-found: error
67+
68+
upload-urcapx:
69+
runs-on: ubuntu-latest
70+
needs: [backend-test, build-frontend-and-test] # Only runs if both jobs succeed
71+
72+
steps:
73+
- name: Download build artifacts
74+
uses: actions/download-artifact@v4
75+
with:
76+
name: build-temp-artifacts
77+
path: target/
78+
79+
- name: Upload final artifacts
4480
uses: actions/upload-artifact@v4
4581
with:
4682
name: external-control-urcapx
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
FROM python:3.11-alpine
1+
FROM python:3.13-alpine
2+
3+
# # Create necessary directories and set permissions
4+
RUN mkdir -p /tmp && chmod 777 /tmp
25

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

710
# Copy the application into the image
8-
COPY src/* .
11+
COPY src/ ./
912

1013
# Tell Flask where to load the application from
11-
ENV FLASK_APP simple-rest-api.py
14+
ENV FLASK_APP simple_rest_api.py
1215

1316
# Expose Flask's default port
1417
EXPOSE 5000
1518

16-
# Run the REST service
17-
ENTRYPOINT ["flask", "run", "--host", "0.0.0.0", "--port", "5000"]
19+
# Run the REST service with Flask development server
20+
ENTRYPOINT ["python", "simple_rest_api.py"]
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
# Flask Framework
2-
Flask==2.0.1
3-
flask-cors==3.0.10
4-
Werkzeug==2.2.2
2+
Flask>=2.2
3+
flask-cors>=3.0.10
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# This file makes the src directory a package for Python imports.

external-control-backend/src/request_program.py

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import socket
2+
import time
23

34
class RequestProgram(object):
45
def __init__(self, port, robotIP):
@@ -49,33 +50,36 @@ def send_command(self, command: str):
4950
str: The program code received from the robot.
5051
5152
Raises:
52-
Exception: If the connection to the remote PC could not be established.
53+
Exception: If the connection to the remote PC could not be established or no data is received.
5354
"""
54-
5555
program = ""
5656
timeout = 5
57-
# Create a socket connection with the robot IP and port number defined above
58-
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
59-
s.connect((self.robotIP, self.port))
60-
s.sendall(command.encode('us-ascii'))
61-
s.settimeout(timeout) # Set timeout for receiving data
62-
63-
#Receive script code
64-
raw_data = b""
65-
while True:
66-
try:
67-
data = s.recv(1024)
68-
if not data:
69-
break
70-
71-
raw_data += data
72-
73-
except socket.timeout:
74-
break
75-
76-
program = raw_data.decode("us-ascii")
77-
78-
# Close the connection
79-
s.close()
80-
81-
return program
57+
try:
58+
# Create a socket connection with the robot IP and port number defined above
59+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
60+
s.settimeout(0.1)
61+
s.connect((self.robotIP, self.port))
62+
s.sendall(command.encode('us-ascii'))
63+
# Receive script code
64+
raw_data = b""
65+
begin = time.time()
66+
while True:
67+
try:
68+
data = s.recv(1024)
69+
if not data:
70+
break # Connection closed by the server
71+
raw_data += data
72+
except socket.timeout:
73+
if raw_data != b"":
74+
print("Done receiving data")
75+
break
76+
elif time.time() - begin > timeout:
77+
s.close()
78+
raise Exception(f"Connection timeout")
79+
program = raw_data.decode("us-ascii")
80+
s.close()
81+
if not bool(program and program.strip()):
82+
raise Exception(f"Did not receive any script lines")
83+
return program
84+
except Exception as e:
85+
raise Exception(f"Connectivity problem with {self.robotIP}:{self.port}: {e}")

external-control-backend/src/simple-rest-api.py

Lines changed: 0 additions & 24 deletions
This file was deleted.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import flask
2+
from flask import Flask, jsonify
3+
from flask_cors import CORS
4+
import request_program
5+
import socket
6+
import time
7+
import logging
8+
import sys
9+
10+
# Configure logging
11+
logging.basicConfig(
12+
level=logging.INFO,
13+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
14+
stream=sys.stdout
15+
)
16+
logger = logging.getLogger(__name__)
17+
18+
command = "request_program\n"
19+
20+
# Create a simple rest api with Flask (https://flask.palletsprojects.com/en/2.0.x/)
21+
app = Flask(__name__)
22+
CORS(app)
23+
24+
# Log startup message
25+
logger.info("Starting Flask application...")
26+
logger.info(f"Server hostname: {socket.gethostname()}")
27+
logger.info(f"Server IP: {socket.gethostbyname(socket.gethostname())}")
28+
29+
# Simple in-memory cache: {(port, robotIP): (timestamp, program)}
30+
program_cache = {}
31+
CACHE_TTL = 2 # seconds
32+
33+
def split_program_sections(program_text):
34+
preamble = ''
35+
program_node = ''
36+
header_start = '# HEADER_BEGIN'
37+
header_end = '# HEADER_END'
38+
start_idx = program_text.find(header_start)
39+
end_idx = program_text.find(header_end)
40+
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
41+
end_idx += len(header_end)
42+
preamble = program_text[start_idx:end_idx]
43+
# Everything after header_end is the program node
44+
program_node = program_text[end_idx:].lstrip('\n')
45+
else:
46+
# If no header, treat all as program_node
47+
program_node = program_text
48+
return preamble, program_node
49+
50+
def get_cached_response(cache_key, now):
51+
if cache_key in program_cache:
52+
ts, json_str = program_cache[cache_key]
53+
if now - ts < CACHE_TTL:
54+
return flask.Response(json_str, mimetype='application/json')
55+
return None
56+
57+
def build_json_response(program, valid, status_text):
58+
preamble, program_node = split_program_sections(program)
59+
json_obj = {
60+
"preamble": preamble,
61+
"program_node": program_node,
62+
"valid": valid,
63+
"status": status_text
64+
}
65+
return flask.json.dumps(json_obj)
66+
67+
def store_in_cache(cache_key, now, json_str, valid):
68+
if valid:
69+
program_cache[cache_key] = (now, json_str)
70+
71+
@app.route('/<int:port>/<robotIP>/', methods=["GET"])
72+
def read_params(port, robotIP):
73+
logger.info(f"Received request for port {port} and robot IP {robotIP}")
74+
cache_key = (port, robotIP)
75+
now = time.time()
76+
cached_resp = get_cached_response(cache_key, now)
77+
if cached_resp:
78+
logger.info(f"Returning cached response for port {port} and robot IP {robotIP}")
79+
return cached_resp
80+
status = None
81+
try:
82+
logger.info(f"Connecting to robot at {robotIP}:{port}")
83+
con = request_program.RequestProgram(port, robotIP)
84+
program = con.send_command(command)
85+
valid = bool(program and program.strip())
86+
status = "ok"
87+
logger.info(f"Successfully retrieved program from robot at {robotIP}:{port}")
88+
except Exception as e:
89+
program = ''
90+
valid = False
91+
status = str(e)
92+
logger.error(f"Error connecting to robot at {robotIP}:{port}: {str(e)}")
93+
json_str = build_json_response(program, valid, status)
94+
store_in_cache(cache_key, now, json_str, valid)
95+
return flask.Response(json_str, mimetype='application/json')
96+
97+
if __name__ == '__main__':
98+
logger.info("Flask application is ready to serve requests")
99+
app.run(host='0.0.0.0', port=5000)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import sys, os
2+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
3+
import pytest
4+
import socket
5+
from request_program import RequestProgram
6+
7+
class DummySocket:
8+
def __init__(self, responses=None, raise_timeout=False, raise_connect=False):
9+
self.responses = responses or []
10+
self.sent = []
11+
self.closed = False
12+
self.recv_calls = 0
13+
self.raise_timeout = raise_timeout
14+
self.raise_connect = raise_connect
15+
self.timeout = None
16+
def settimeout(self, timeout):
17+
self.timeout = timeout
18+
def connect(self, addr):
19+
if self.raise_connect:
20+
raise socket.error("connect error")
21+
def sendall(self, data):
22+
self.sent.append(data)
23+
def recv(self, bufsize):
24+
if self.raise_timeout:
25+
raise socket.timeout("timed out")
26+
if self.recv_calls < len(self.responses):
27+
resp = self.responses[self.recv_calls]
28+
self.recv_calls += 1
29+
return resp
30+
return b''
31+
def close(self):
32+
self.closed = True
33+
34+
@pytest.fixture(autouse=True)
35+
def patch_socket(monkeypatch):
36+
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: DummySocket())
37+
38+
def test_send_command_success(monkeypatch):
39+
responses = [b'# HEADER_BEGIN\nheader\n# HEADER_END\n', b'print("hi")\n']
40+
dummy = DummySocket(responses=responses)
41+
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: dummy)
42+
rp = RequestProgram(1234, '127.0.0.1')
43+
result = rp.send_command('request_program\n')
44+
assert '# HEADER_BEGIN\nheader\n# HEADER_END' in result
45+
assert 'print("hi")' in result
46+
assert dummy.closed
47+
48+
def test_send_command_no_data(monkeypatch):
49+
dummy = DummySocket(responses=[])
50+
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: dummy)
51+
rp = RequestProgram(1234, '127.0.0.1')
52+
with pytest.raises(Exception) as exc:
53+
rp.send_command('request_program\n')
54+
assert (
55+
'Connectivity problem with 127.0.0.1:1234: Did not receive any script lines' in str(exc.value)
56+
)
57+
assert dummy.closed
58+
59+
def test_send_command_timeout(monkeypatch):
60+
dummy = DummySocket(raise_timeout=True)
61+
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: dummy)
62+
rp = RequestProgram(1234, '127.0.0.1')
63+
with pytest.raises(Exception) as exc:
64+
rp.send_command('request_program\n')
65+
assert 'Connectivity problem with 127.0.0.1:1234: Connection timeout' in str(exc.value)
66+
assert dummy.closed
67+
68+
def test_send_command_connect_error(monkeypatch):
69+
dummy = DummySocket(raise_connect=True)
70+
monkeypatch.setattr(socket, 'socket', lambda *a, **kw: dummy)
71+
rp = RequestProgram(1234, '127.0.0.1')
72+
with pytest.raises(Exception) as exc:
73+
rp.send_command('request_program\n')
74+
assert 'connect error' in str(exc.value)
75+
assert dummy.closed or not dummy.closed # connect error may not close

0 commit comments

Comments
 (0)