-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
60 sofast curl interface #75
Changes from all commits
b7ae7d0
9df3cf4
11ee8fa
6193382
3f9560b
de6627d
bb6e412
9562b05
6039989
086d2c2
c12c87d
ef46859
cbd7bc5
db6a5f6
0735f1a
d91fb98
d0cb901
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,224 @@ | ||
""" | ||
A simple (unsecured & slow) web-server to provide a cURL API to the SOFAST systems. | ||
|
||
Starter code for this server from "How to Launch an HTTP Server in One Line of Python Code" by realpython.com | ||
(https://realpython.com/python-http-server/). | ||
""" | ||
|
||
from concurrent.futures import ThreadPoolExecutor | ||
import dataclasses | ||
import gc | ||
import json | ||
from functools import cached_property | ||
from http.cookies import SimpleCookie | ||
from http.server import BaseHTTPRequestHandler, HTTPServer | ||
import os | ||
from traceback import format_exception | ||
from urllib.parse import parse_qsl, urlparse | ||
|
||
import numpy as np | ||
|
||
import opencsp.app.sofast.lib.ServerState as ss | ||
from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection | ||
from opencsp.common.lib.opencsp_path import opencsp_settings | ||
import opencsp.common.lib.tool.file_tools as ft | ||
import opencsp.common.lib.tool.log_tools as lt | ||
import opencsp.common.lib.tool.time_date_tools as tdt | ||
|
||
|
||
@dataclasses.dataclass | ||
class _UrlParseResult: | ||
"""Helper class to define the slot names of the ParseResult type""" | ||
|
||
scheme: str | ||
net_location: str | ||
path: str | ||
params: str | ||
query: str | ||
fragment: str | ||
|
||
|
||
class SofastServer(BaseHTTPRequestHandler): | ||
@cached_property | ||
def url(self) -> _UrlParseResult: | ||
return urlparse(self.path) | ||
|
||
@cached_property | ||
def query_data(self): | ||
return dict(parse_qsl(self.url.query)) | ||
|
||
@cached_property | ||
def post_data(self): | ||
content_length = int(self.headers.get("Content-Length", 0)) | ||
return self.rfile.read(content_length) | ||
|
||
@cached_property | ||
def form_data(self): | ||
return dict(parse_qsl(self.post_data.decode("utf-8"))) | ||
|
||
@cached_property | ||
def cookies(self): | ||
return SimpleCookie(self.headers.get("Cookie")) | ||
|
||
def do_GET(self): | ||
response_code, response_msg = self.get_response() | ||
self.send_response(response_code) | ||
self.send_header("Content-Type", "application/json") | ||
self.end_headers() | ||
self.wfile.write(response_msg.encode("utf-8")) | ||
|
||
def do_POST(self): | ||
self.do_GET() | ||
|
||
def get_response(self) -> tuple[int, str]: | ||
action = "N/A" | ||
ret = {"error": None} | ||
response_code = 200 | ||
|
||
try: | ||
if "/" in self.url.path: | ||
action = self.url.path.split("/")[-1] | ||
else: | ||
action = self.url.path | ||
|
||
if action == "help": | ||
ret["actions"] = [ | ||
"help", | ||
"start_measure_fringes", | ||
"is_busy", | ||
"save_measure_fringes", | ||
"get_results_fringes", | ||
] | ||
|
||
if action == "start_measure_fringes": | ||
with ss.ServerState.instance() as state: | ||
name = self.query_data["name"] | ||
ret["success"] = state.start_measure_fringes(name) | ||
|
||
elif action == "is_busy": | ||
with ss.ServerState.instance() as state: | ||
ret["is_busy"] = state.busy | ||
|
||
elif action == "save_measure_fringes": | ||
if "saves_output_dir" in opencsp_settings and ft.directory_exists(opencsp_settings["saves_output_dir"]): | ||
measurement = None | ||
processing_error = None | ||
with ss.ServerState.instance() as state: | ||
if state.has_fringe_measurement: | ||
measurement = state.last_measurement_fringe[0] | ||
file_name_ext = state.fringe_measurement_name + ".h5" | ||
else: | ||
processing_error = state.processing_error | ||
if measurement is not None: | ||
file_path_name_ext = os.path.join(opencsp_settings["saves_output_dir"], file_name_ext) | ||
measurement.save_to_hdf(file_path_name_ext) | ||
ret["file_name_ext"] = file_name_ext | ||
elif processing_error is not None: | ||
ret["error"] = ( | ||
f"Unexpected {repr(processing_error)} error encountered during measurement processing" | ||
) | ||
ret["trace"] = "".join(format_exception(processing_error)) | ||
response_code = 500 | ||
else: | ||
ret["error"] = "Fringe measurement is not ready" | ||
ret["trace"] = "SofastServer.get_response::save_measure_fringes" | ||
response_code = 409 | ||
else: | ||
ret["error"] = "Measurements save directory not speicified in settings" | ||
ret["trace"] = "SofastServer.get_response::save_measure_fringes" | ||
response_code = 500 | ||
|
||
elif action == "get_results_fringes": | ||
measurement = None | ||
with ss.ServerState.instance() as state: | ||
if state.has_fringe_measurement: | ||
measurement = state.last_measurement_fringe | ||
state.system_fringe | ||
if measurement is not None: | ||
ret.update( | ||
{ | ||
"focal_length_x": measurement.focal_length_x, | ||
"focal_length_y": measurement.focal_length_y, | ||
"slope_error_x": np.average(measurement.slopes_error[0]), | ||
"slope_error_y": np.average(measurement.slopes_error[1]), | ||
"slope_error": np.average(measurement.slopes_error), | ||
"slope_stddev": np.std(measurement.slopes_error), | ||
} | ||
) | ||
else: | ||
ret["error"] = "Fringe measurement is not ready" | ||
ret["trace"] = "SofastServer.get_response::get_results_fringes" | ||
response_code = 409 | ||
|
||
else: | ||
ret["error"] = f"Unknown action \"{action}\"" | ||
ret["trace"] = "SofastServer.get_response::N/A" | ||
response_code = 404 | ||
|
||
except Exception as ex: | ||
lt.error("Error in SofastServer with action " + action + ": " + repr(ex)) | ||
ret["error"] = (repr(ex),) | ||
ret["trace"] = "".join(format_exception(ex)) | ||
response_code = 500 | ||
|
||
# sanity check: did we synchronize the error and response_code? | ||
if response_code != 200: | ||
if ret["error"] is None: | ||
lt.error_and_raise( | ||
RuntimeError, | ||
f"Programmer error in SofastServer.get_response({action}): " | ||
+ f"did not correctly set 'error' to match {response_code=}!", | ||
) | ||
if ret["error"] is not None: | ||
if response_code == 200: | ||
lt.error_and_raise( | ||
RuntimeError, | ||
f"Programmer error in SofastServer.get_response({action}): " | ||
+ f"did not correctly set response_code to match {ret['error']=}!", | ||
) | ||
|
||
return response_code, json.dumps(ret) | ||
|
||
|
||
if __name__ == "__main__": | ||
port = 8000 | ||
|
||
# Set up the logger | ||
log_output_dir = opencsp_settings["sofast_server"]["log_output_dir"] | ||
if log_output_dir is not None and ft.directory_exists(log_output_dir): | ||
log_name_ext = "SofastServer_" + tdt.current_date_time_string_forfile() + ".log" | ||
log_path_name_ext = os.path.join(log_output_dir, log_name_ext) | ||
lt.logger(log_path_name_ext) | ||
|
||
# Start the server | ||
lt.warn( | ||
"Warning in SofastServer: this server is unsecured. " | ||
+ f"It is suggested that you restrict outside access to port {port} of the host computer." | ||
) | ||
lt.info(f"Starting server on port {port}...") | ||
server = HTTPServer(("0.0.0.0", port), SofastServer) | ||
|
||
# Initialize the IO devices | ||
ss.ServerState() | ||
with ss.ServerState.instance() as state: | ||
state.init_io() | ||
state.load_default_settings() | ||
|
||
# Lock in the currently allocated memory, to improve garbage collector performance | ||
gc.collect() | ||
gc.freeze() | ||
|
||
# Start a new thread for the server | ||
# The minimum time between server evaulation loops is determined by the GIL: | ||
# https://docs.python.org/3/library/sys.html#sys.setswitchinterval | ||
server_pool = ThreadPoolExecutor(max_workers=1) | ||
server_pool.submit(server.serve_forever) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens if this thread crashes or throws an exception? Will the program exit cleanly? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a valid concern and actually why I'm doing this work. The intent is to have a separate program that restarts the entire process if the server stops responding. |
||
|
||
# Start the GUI thread | ||
ImageProjection.instance().root.mainloop() | ||
|
||
# GUI has exited, shutdown everything | ||
with ss.ServerState.instance() as state: | ||
state.close_all() | ||
server.shutdown() | ||
server_pool.shutdown() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
_sofast_server_settings_key = "sofast_server" | ||
_sofast_server_settings: dict[str, any] = {"log_output_dir": None, "saves_output_dir": None} | ||
|
||
_sofast_defaults_settings_key = "sofast_defaults" | ||
_sofast_defaults_settings: dict[str, any] = { | ||
"camera_names_and_indexes": None, | ||
"projector_file": None, | ||
"calibration_file": None, | ||
"mirror_measure_point": None, | ||
"mirror_screen_distance": None, | ||
"camera_calibration_file": None, | ||
"spatial_orientation_file": None, | ||
"display_shape_file": None, | ||
"facet_definition_files": None, | ||
"ensemble_definition_file": None, | ||
"reference_facet_file": None, | ||
"surface_shape_file": None, | ||
"fixed_pattern_diameter_and_spacing": None, | ||
"dot_locations_file": None, | ||
"num_fringe_periods": None, | ||
} | ||
""" | ||
log_output_dir: Where to save log output to from the server. | ||
camera_files: Where to find the camera .h5 file(s), which define the default cameras to connect to on server start. | ||
projector_file: Where to find the projection .h5 file, which defines the default screen space for the projector. | ||
calibration_file: Where to find the calibration .h5 file, which defines the default camera-screen response calibration. | ||
""" | ||
|
||
_settings_list = [ | ||
[_sofast_server_settings_key, _sofast_server_settings], | ||
[_sofast_defaults_settings_key, _sofast_defaults_settings], | ||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider storing "0.0.0.0" as a local variable above near where
port
is defined.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, this is a good idea and one I will implement.
Hey, sorry about the confusion. This PR isn't ready for review yet (if I assigned you already that was a mistake). I'll let you know when it is ready.