axi-control 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- axi_control-0.1.1/PKG-INFO +12 -0
- axi_control-0.1.1/README.md +0 -0
- axi_control-0.1.1/pyproject.toml +36 -0
- axi_control-0.1.1/src/axi_control/__init__.py +6 -0
- axi_control-0.1.1/src/axi_control/_axi_server.py +145 -0
- axi_control-0.1.1/src/axi_control/axicontroller.py +271 -0
- axi_control-0.1.1/src/axi_control/cli.py +196 -0
- axi_control-0.1.1/src/axi_control/progress.py +52 -0
- axi_control-0.1.1/src/axi_control/routes/control.py +81 -0
- axi_control-0.1.1/src/axi_control/routes/plotting.py +298 -0
- axi_control-0.1.1/src/axi_control/routes/status.py +182 -0
- axi_control-0.1.1/src/axi_control/servercontext.py +64 -0
- axi_control-0.1.1/src/axi_control/utils.py +88 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: axi-control
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Dist: axicli
|
|
6
|
+
Requires-Dist: flask>=3.1.2
|
|
7
|
+
Requires-Dist: ipykernel>=7.1.0
|
|
8
|
+
Requires-Dist: ipywidgets>=8.1.8
|
|
9
|
+
Requires-Dist: platformdirs>=4.2.0
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "axi-control"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Add your description here"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"axicli",
|
|
13
|
+
"flask>=3.1.2",
|
|
14
|
+
"ipykernel>=7.1.0",
|
|
15
|
+
"ipywidgets>=8.1.8",
|
|
16
|
+
"platformdirs>=4.2.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
axicontrol-server = "axi_control.cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.uv.workspace]
|
|
23
|
+
members = ["axidraw"]
|
|
24
|
+
|
|
25
|
+
[tool.uv]
|
|
26
|
+
required-version = ">=0.7.0"
|
|
27
|
+
|
|
28
|
+
[tool.uv.sources]
|
|
29
|
+
axicli = { workspace = true }
|
|
30
|
+
|
|
31
|
+
[tool.uv-ship]
|
|
32
|
+
release-branch = false
|
|
33
|
+
allow-dirty = false
|
|
34
|
+
changelog-path = "CHANGELOG.md"
|
|
35
|
+
date-first = true
|
|
36
|
+
changelog-template = "- {message}"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from . import utils as utils
|
|
2
|
+
from .axicontroller import AxiController as AxiController
|
|
3
|
+
from .routes import control as control
|
|
4
|
+
from .routes import plotting as plotting
|
|
5
|
+
from .routes import status as status
|
|
6
|
+
from .servercontext import ServerContext as ServerContext
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# server.py
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from axidrawinternal.plot_utils_import import from_dependency_import
|
|
12
|
+
from flask import Flask, g, request
|
|
13
|
+
|
|
14
|
+
import axi_control as axc
|
|
15
|
+
|
|
16
|
+
SRV_OPTS = 'config/srv_options.json'
|
|
17
|
+
PID_FILE = Path('/tmp/axi_server.pid')
|
|
18
|
+
|
|
19
|
+
LOCKED_ENDPOINTS = {
|
|
20
|
+
'nudge_x',
|
|
21
|
+
'nudge_y',
|
|
22
|
+
'home',
|
|
23
|
+
'move_xy',
|
|
24
|
+
'move_to',
|
|
25
|
+
'set_home',
|
|
26
|
+
'toggle_pen',
|
|
27
|
+
'cycle_pen',
|
|
28
|
+
'disable_motors',
|
|
29
|
+
'enable_motors',
|
|
30
|
+
'prime',
|
|
31
|
+
'motor_state',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------- setup paths & logging ----------
|
|
36
|
+
log_dir = Path(os.getcwd()) / 'logs'
|
|
37
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
40
|
+
log_filename = log_dir / 'axiserver.log'
|
|
41
|
+
plot_status_log_filename = log_dir / 'plot_status.log'
|
|
42
|
+
|
|
43
|
+
logging.basicConfig(
|
|
44
|
+
filename=log_filename,
|
|
45
|
+
filemode='w',
|
|
46
|
+
level=logging.INFO,
|
|
47
|
+
format='%(asctime)s %(levelname)s: %(message)s',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# logging.getLogger('werkzeug').addFilter(axc.utils.RequestFilter())
|
|
51
|
+
SKIP_LOG_PATHS = {} # {'/log', '/axidraw_status', '/motor_state'}
|
|
52
|
+
# ˆˆˆ these tend to be very frequent and clutter the access log. replaces above
|
|
53
|
+
logging.getLogger('werkzeug').setLevel(logging.WARNING)
|
|
54
|
+
ACCESS_LOGGER = logging.getLogger('axi_access')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------- app & controller ----------
|
|
58
|
+
def create_app(artwork_root: str | None):
|
|
59
|
+
file_root = axc.utils.select_file_root(artwork_root)
|
|
60
|
+
app = Flask(__name__)
|
|
61
|
+
ad = axc.AxiController()
|
|
62
|
+
ebb_serial = from_dependency_import('plotink.ebb_serial')
|
|
63
|
+
ctx = axc.ServerContext(
|
|
64
|
+
ad=ad,
|
|
65
|
+
ebb_serial=ebb_serial,
|
|
66
|
+
file_root=file_root,
|
|
67
|
+
srv_opts_path=SRV_OPTS,
|
|
68
|
+
log_filename=log_filename,
|
|
69
|
+
plot_status_log_filename=plot_status_log_filename,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@app.before_request
|
|
73
|
+
def _lock_ad_access():
|
|
74
|
+
if request.endpoint in LOCKED_ENDPOINTS:
|
|
75
|
+
ctx.ad_lock.acquire()
|
|
76
|
+
g.ad_lock_acquired = True
|
|
77
|
+
|
|
78
|
+
@app.teardown_request
|
|
79
|
+
def _unlock_ad_access(_exc):
|
|
80
|
+
if getattr(g, 'ad_lock_acquired', False):
|
|
81
|
+
ctx.ad_lock.release()
|
|
82
|
+
|
|
83
|
+
@app.after_request
|
|
84
|
+
def _log_access(response):
|
|
85
|
+
if request.path in SKIP_LOG_PATHS:
|
|
86
|
+
return response
|
|
87
|
+
remote_addr = request.remote_addr or '-'
|
|
88
|
+
path = request.full_path.rstrip('?')
|
|
89
|
+
size = response.calculate_content_length()
|
|
90
|
+
size_text = size if size is not None else '-'
|
|
91
|
+
ACCESS_LOGGER.info(
|
|
92
|
+
'%s "%s %s" %s %s',
|
|
93
|
+
remote_addr,
|
|
94
|
+
request.method,
|
|
95
|
+
path,
|
|
96
|
+
response.status_code,
|
|
97
|
+
size_text,
|
|
98
|
+
)
|
|
99
|
+
return response
|
|
100
|
+
|
|
101
|
+
axc.control.register(app, ctx)
|
|
102
|
+
axc.status.register(app, ctx)
|
|
103
|
+
axc.plotting.register(app, ctx)
|
|
104
|
+
return app, ctx
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cleanup(signum=None, frame=None):
|
|
108
|
+
logging.info('Cleaning up, signal=%s', signum)
|
|
109
|
+
PID_FILE.unlink(missing_ok=True)
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------- entry point ----------
|
|
114
|
+
def _parse_args() -> argparse.Namespace:
|
|
115
|
+
parser = argparse.ArgumentParser(description='AxiControl Flask server')
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
'--dev',
|
|
118
|
+
action='store_true',
|
|
119
|
+
help='Skip hardware connection checks for local development',
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
'--artwork-root',
|
|
123
|
+
default=None,
|
|
124
|
+
help='Root directory for artwork SVGs (overrides ARTWORK_ROOT).',
|
|
125
|
+
)
|
|
126
|
+
return parser.parse_args()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == '__main__':
|
|
130
|
+
args = _parse_args()
|
|
131
|
+
app, ctx = create_app(args.artwork_root)
|
|
132
|
+
logging.info('Artwork root set to %s', ctx.file_root)
|
|
133
|
+
|
|
134
|
+
if not args.dev:
|
|
135
|
+
if not ctx.ad.connect():
|
|
136
|
+
logging.error('AxiController connection failed')
|
|
137
|
+
raise RuntimeError('AxiController connection failed')
|
|
138
|
+
# write PID only for the real server process
|
|
139
|
+
PID_FILE.write_text(str(os.getpid()))
|
|
140
|
+
|
|
141
|
+
# clean shutdown
|
|
142
|
+
signal.signal(signal.SIGTERM, cleanup)
|
|
143
|
+
signal.signal(signal.SIGINT, cleanup)
|
|
144
|
+
|
|
145
|
+
app.run(host='0.0.0.0', port=5050, threaded=True)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from axidrawinternal.plot_utils_import import from_dependency_import
|
|
6
|
+
from pyaxidraw.axidraw import AxiDraw
|
|
7
|
+
|
|
8
|
+
from . import utils
|
|
9
|
+
|
|
10
|
+
ebb_motion = from_dependency_import('plotink.ebb_motion')
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SCRIPT_PATH = Path(__file__).resolve()
|
|
14
|
+
repo_path = SCRIPT_PATH.parent.parent.parent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AxiController(AxiDraw):
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self._force_cli_api = False
|
|
20
|
+
self._forced_total_mm = None
|
|
21
|
+
super().__init__(user_message_fun=logger.info)
|
|
22
|
+
|
|
23
|
+
setattr(self.options, 'units', 0) # set the default right away
|
|
24
|
+
|
|
25
|
+
def set_defaults(self):
|
|
26
|
+
super().set_defaults()
|
|
27
|
+
self.plot_status.progress.enable = False
|
|
28
|
+
self.plot_status.progress.dry_run = False
|
|
29
|
+
self.plot_status.progress.total = 0
|
|
30
|
+
self.plot_status.progress.last = 0
|
|
31
|
+
self.plot_status.progress.p_bar = None
|
|
32
|
+
self.plot_status.progress.sub_bar = None
|
|
33
|
+
if self._force_cli_api:
|
|
34
|
+
self.plot_status.cli_api = True
|
|
35
|
+
self.options.progress = True
|
|
36
|
+
if self._forced_total_mm is not None:
|
|
37
|
+
self.plot_status.progress.total = self._forced_total_mm
|
|
38
|
+
|
|
39
|
+
def _estimate_total_mm(self, file) -> float | None:
|
|
40
|
+
self.plot_setup(file)
|
|
41
|
+
time.sleep(0.2)
|
|
42
|
+
self.options.mode = 'plot'
|
|
43
|
+
self.options.preview = True
|
|
44
|
+
self.options.rendering = 0
|
|
45
|
+
self.options.digest = 0
|
|
46
|
+
self.options.progress = False
|
|
47
|
+
self.options.progress_no_dry_run = True
|
|
48
|
+
self._force_cli_api = False
|
|
49
|
+
self.plot_status.cli_api = False
|
|
50
|
+
self.apply_srv_options()
|
|
51
|
+
try:
|
|
52
|
+
self.plot_run()
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
logger.warning('Estimate failed: %s', exc)
|
|
55
|
+
return None
|
|
56
|
+
total_inch = self.plot_status.stats.down_travel_inch + self.plot_status.stats.up_travel_inch
|
|
57
|
+
return 25.4 * total_inch
|
|
58
|
+
|
|
59
|
+
def plot_file(
|
|
60
|
+
self,
|
|
61
|
+
file,
|
|
62
|
+
enable_progress: bool = False,
|
|
63
|
+
allow_estimate: bool = True,
|
|
64
|
+
return_output: bool = False,
|
|
65
|
+
):
|
|
66
|
+
total_mm = None
|
|
67
|
+
if enable_progress and allow_estimate:
|
|
68
|
+
total_mm = self._estimate_total_mm(file)
|
|
69
|
+
|
|
70
|
+
self.plot_setup(file)
|
|
71
|
+
time.sleep(0.5) # Wait half second
|
|
72
|
+
self.options.mode = 'plot'
|
|
73
|
+
self.options.preview = False
|
|
74
|
+
self.options.digest = 0
|
|
75
|
+
self.options.progress_no_dry_run = True
|
|
76
|
+
self._force_cli_api = enable_progress
|
|
77
|
+
self.plot_status.cli_api = enable_progress
|
|
78
|
+
self.options.progress = enable_progress
|
|
79
|
+
self._forced_total_mm = total_mm if total_mm is not None else None
|
|
80
|
+
logger.info(
|
|
81
|
+
'Plot start: progress=%s cli_api=%s preview=%s digest=%s mode=%s estimate=%s',
|
|
82
|
+
self.options.progress,
|
|
83
|
+
self.plot_status.cli_api,
|
|
84
|
+
self.options.preview,
|
|
85
|
+
self.options.digest,
|
|
86
|
+
self.options.mode,
|
|
87
|
+
allow_estimate,
|
|
88
|
+
)
|
|
89
|
+
self.apply_srv_options()
|
|
90
|
+
output_svg = self.plot_run(output=return_output)
|
|
91
|
+
if return_output:
|
|
92
|
+
return output_svg, self.plot_status.stopped
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def resume_plot(self, svg_input, enable_progress: bool = False):
|
|
96
|
+
self.plot_setup(svg_input)
|
|
97
|
+
time.sleep(0.5) # Wait half second
|
|
98
|
+
self.options.mode = 'res_plot'
|
|
99
|
+
self.options.preview = False
|
|
100
|
+
self.options.digest = 0
|
|
101
|
+
self.options.progress_no_dry_run = True
|
|
102
|
+
self._force_cli_api = enable_progress
|
|
103
|
+
self.plot_status.cli_api = enable_progress
|
|
104
|
+
self.options.progress = enable_progress
|
|
105
|
+
logger.info(
|
|
106
|
+
'Resume start: progress=%s cli_api=%s preview=%s digest=%s mode=%s',
|
|
107
|
+
self.options.progress,
|
|
108
|
+
self.plot_status.cli_api,
|
|
109
|
+
self.options.preview,
|
|
110
|
+
self.options.digest,
|
|
111
|
+
self.options.mode,
|
|
112
|
+
)
|
|
113
|
+
self.apply_srv_options()
|
|
114
|
+
output_svg = self.plot_run(output=True)
|
|
115
|
+
return output_svg, self.plot_status.stopped
|
|
116
|
+
|
|
117
|
+
def apply_srv_options(self, srv_options: dict = None):
|
|
118
|
+
if srv_options is None:
|
|
119
|
+
file = repo_path / 'config' / 'srv_options.json'
|
|
120
|
+
srv_options = utils.load_srv_options(file)
|
|
121
|
+
|
|
122
|
+
for k, v in srv_options.items():
|
|
123
|
+
if hasattr(self.options, k):
|
|
124
|
+
if k == 'units':
|
|
125
|
+
unit_map = {'mm': 2, 'cm': 1, 'in': 0}
|
|
126
|
+
v = unit_map.get(v, 0)
|
|
127
|
+
|
|
128
|
+
setattr(self.options, k, v)
|
|
129
|
+
|
|
130
|
+
def motor_state(self):
|
|
131
|
+
temp_connected = False
|
|
132
|
+
try:
|
|
133
|
+
if not self.connected or self.plot_status.port is None:
|
|
134
|
+
self.serial_connect()
|
|
135
|
+
temp_connected = True
|
|
136
|
+
if self.plot_status.port is None:
|
|
137
|
+
return None
|
|
138
|
+
res = ebb_motion.query_enable_motors(self.plot_status.port, False)
|
|
139
|
+
if not res or res[0] is None or res[1] is None:
|
|
140
|
+
return None
|
|
141
|
+
return res[0] > 0 and res[1] > 0
|
|
142
|
+
except Exception:
|
|
143
|
+
return None
|
|
144
|
+
finally:
|
|
145
|
+
if temp_connected:
|
|
146
|
+
self.disconnect()
|
|
147
|
+
|
|
148
|
+
def _util(self, func, **kwargs):
|
|
149
|
+
self.plot_setup()
|
|
150
|
+
self.options.mode = 'manual'
|
|
151
|
+
self.options.manual_cmd = func
|
|
152
|
+
|
|
153
|
+
for k, v in kwargs.items():
|
|
154
|
+
if v is not None:
|
|
155
|
+
if hasattr(self.options, k):
|
|
156
|
+
setattr(self.options, k, v)
|
|
157
|
+
|
|
158
|
+
self.apply_srv_options()
|
|
159
|
+
self.plot_run()
|
|
160
|
+
|
|
161
|
+
def util_motors_on(self):
|
|
162
|
+
self._util('enable_xy')
|
|
163
|
+
|
|
164
|
+
def util_motors_off(self):
|
|
165
|
+
self._util('disable_xy')
|
|
166
|
+
|
|
167
|
+
def util_cycle(self):
|
|
168
|
+
self.i_pendown()
|
|
169
|
+
self.i_delay(500)
|
|
170
|
+
self.i_penup()
|
|
171
|
+
# self.disconnect()
|
|
172
|
+
|
|
173
|
+
def util_toggle(self):
|
|
174
|
+
self.i_penup() if not self.i_current_pen() else self.i_pendown()
|
|
175
|
+
# self.disconnect()
|
|
176
|
+
|
|
177
|
+
def util_align(self):
|
|
178
|
+
self.plot_setup()
|
|
179
|
+
self.options.mode = 'align'
|
|
180
|
+
self.apply_srv_options()
|
|
181
|
+
self.plot_run()
|
|
182
|
+
|
|
183
|
+
def util_home(self):
|
|
184
|
+
self._util('walk_home')
|
|
185
|
+
|
|
186
|
+
def util_nudge_x(self, dist, unit='mm'):
|
|
187
|
+
if unit == 'in':
|
|
188
|
+
self._util('walk_x', dist=dist)
|
|
189
|
+
elif unit == 'mm':
|
|
190
|
+
self._util('walk_mmx', dist=dist)
|
|
191
|
+
else:
|
|
192
|
+
raise ValueError('Invalid unit for walk_x: use "mm" or "in"')
|
|
193
|
+
|
|
194
|
+
def util_nudge_y(self, dist, unit='mm'):
|
|
195
|
+
if unit == 'in':
|
|
196
|
+
self._util('walk_y', dist=dist)
|
|
197
|
+
elif unit == 'mm':
|
|
198
|
+
self._util('walk_mmy', dist=dist)
|
|
199
|
+
else:
|
|
200
|
+
raise ValueError('Invalid unit for walk_y: use "mm" or "in"')
|
|
201
|
+
|
|
202
|
+
############ Interactive commands
|
|
203
|
+
def _iactive(self, func, *args, **kwargs) -> None:
|
|
204
|
+
if not self.options.mode == 'interactive':
|
|
205
|
+
print('Switching to interactive mode...')
|
|
206
|
+
self.interactive()
|
|
207
|
+
|
|
208
|
+
self.apply_srv_options()
|
|
209
|
+
|
|
210
|
+
if not self.connected:
|
|
211
|
+
print('Connecting to AxiDraw device...')
|
|
212
|
+
self.connect()
|
|
213
|
+
if not self.connected:
|
|
214
|
+
raise ConnectionError('Could not connect to AxiDraw device.')
|
|
215
|
+
|
|
216
|
+
self.update() # when options are changed after connect
|
|
217
|
+
res = func(*args, **kwargs)
|
|
218
|
+
return res
|
|
219
|
+
|
|
220
|
+
# self.disconnect()
|
|
221
|
+
|
|
222
|
+
def i_goto(self, x_target: float, y_target: float) -> None:
|
|
223
|
+
return self._iactive(self.goto, x_target=x_target, y_target=y_target)
|
|
224
|
+
|
|
225
|
+
def i_moveto(self, x_target: float, y_target: float) -> None:
|
|
226
|
+
return self._iactive(self.moveto, x_target=x_target, y_target=y_target)
|
|
227
|
+
|
|
228
|
+
def i_lineto(self, x_target: float, y_target: float) -> None:
|
|
229
|
+
return self._iactive(self.lineto, x_target=x_target, y_target=y_target)
|
|
230
|
+
|
|
231
|
+
def i_go(self, delta_x: float, delta_y: float) -> None:
|
|
232
|
+
return self._iactive(self.go, delta_x, delta_y)
|
|
233
|
+
|
|
234
|
+
def i_move(self, delta_x: float, delta_y: float) -> None:
|
|
235
|
+
return self._iactive(self.move, delta_x, delta_y)
|
|
236
|
+
|
|
237
|
+
def i_line(self, delta_x: float, delta_y: float) -> None:
|
|
238
|
+
return self._iactive(self.line, delta_x=delta_x, delta_y=delta_y)
|
|
239
|
+
|
|
240
|
+
def i_penup(self) -> None:
|
|
241
|
+
return self._iactive(self.penup)
|
|
242
|
+
|
|
243
|
+
def i_pendown(self) -> None:
|
|
244
|
+
return self._iactive(self.pendown)
|
|
245
|
+
|
|
246
|
+
def i_draw_path(self, vertex_list) -> None:
|
|
247
|
+
return self._iactive(self.draw_path, vertex_list=vertex_list)
|
|
248
|
+
|
|
249
|
+
def i_delay(self, time_ms) -> None:
|
|
250
|
+
return self._iactive(self.delay, time_ms=time_ms)
|
|
251
|
+
|
|
252
|
+
def i_block(self) -> None:
|
|
253
|
+
return self._iactive(self.block)
|
|
254
|
+
|
|
255
|
+
def i_current_pos(self) -> None:
|
|
256
|
+
return self._iactive(self.current_pos)
|
|
257
|
+
|
|
258
|
+
def i_turtle_pos(self) -> None:
|
|
259
|
+
return self._iactive(self.turtle_pos)
|
|
260
|
+
|
|
261
|
+
def i_current_pen(self) -> None:
|
|
262
|
+
return self._iactive(self.current_pen)
|
|
263
|
+
|
|
264
|
+
def i_turtle_pen(self) -> None:
|
|
265
|
+
return self._iactive(self.turtle_pen)
|
|
266
|
+
|
|
267
|
+
def i_usb_command(self) -> None:
|
|
268
|
+
return self._iactive(self.usb_command)
|
|
269
|
+
|
|
270
|
+
def i_usb_query(self) -> None:
|
|
271
|
+
return self._iactive(self.usb_query)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import platformdirs
|
|
9
|
+
from axidrawinternal.plot_utils_import import from_dependency_import
|
|
10
|
+
from flask import Flask, g, request
|
|
11
|
+
|
|
12
|
+
import axi_control as axc
|
|
13
|
+
|
|
14
|
+
APP_NAME = "axi-control"
|
|
15
|
+
APP_AUTHOR = "AxiControl"
|
|
16
|
+
PID_FILE = Path("/tmp/axi_server.pid")
|
|
17
|
+
|
|
18
|
+
LOCKED_ENDPOINTS = {
|
|
19
|
+
"nudge_x",
|
|
20
|
+
"nudge_y",
|
|
21
|
+
"home",
|
|
22
|
+
"move_xy",
|
|
23
|
+
"move_to",
|
|
24
|
+
"set_home",
|
|
25
|
+
"toggle_pen",
|
|
26
|
+
"cycle_pen",
|
|
27
|
+
"disable_motors",
|
|
28
|
+
"enable_motors",
|
|
29
|
+
"prime",
|
|
30
|
+
"motor_state",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_app(artwork_root: str | None, srv_opts_path: Path, log_filename: Path, plot_status_log_filename: Path):
|
|
35
|
+
file_root = axc.utils.select_file_root(artwork_root)
|
|
36
|
+
app = Flask(__name__)
|
|
37
|
+
ad = axc.AxiController()
|
|
38
|
+
ebb_serial = from_dependency_import("plotink.ebb_serial")
|
|
39
|
+
ctx = axc.ServerContext(
|
|
40
|
+
ad=ad,
|
|
41
|
+
ebb_serial=ebb_serial,
|
|
42
|
+
file_root=file_root,
|
|
43
|
+
srv_opts_path=srv_opts_path,
|
|
44
|
+
log_filename=log_filename,
|
|
45
|
+
plot_status_log_filename=plot_status_log_filename,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@app.before_request
|
|
49
|
+
def _lock_ad_access():
|
|
50
|
+
if request.endpoint in LOCKED_ENDPOINTS:
|
|
51
|
+
ctx.ad_lock.acquire()
|
|
52
|
+
g.ad_lock_acquired = True
|
|
53
|
+
|
|
54
|
+
@app.teardown_request
|
|
55
|
+
def _unlock_ad_access(_exc):
|
|
56
|
+
if getattr(g, "ad_lock_acquired", False):
|
|
57
|
+
ctx.ad_lock.release()
|
|
58
|
+
|
|
59
|
+
@app.after_request
|
|
60
|
+
def _log_access(response):
|
|
61
|
+
if request.path in SKIP_LOG_PATHS:
|
|
62
|
+
return response
|
|
63
|
+
remote_addr = request.remote_addr or "-"
|
|
64
|
+
path = request.full_path.rstrip("?")
|
|
65
|
+
size = response.calculate_content_length()
|
|
66
|
+
size_text = size if size is not None else "-"
|
|
67
|
+
ACCESS_LOGGER.info(
|
|
68
|
+
"%s \"%s %s\" %s %s",
|
|
69
|
+
remote_addr,
|
|
70
|
+
request.method,
|
|
71
|
+
path,
|
|
72
|
+
response.status_code,
|
|
73
|
+
size_text,
|
|
74
|
+
)
|
|
75
|
+
return response
|
|
76
|
+
|
|
77
|
+
axc.control.register(app, ctx)
|
|
78
|
+
axc.status.register(app, ctx)
|
|
79
|
+
axc.plotting.register(app, ctx)
|
|
80
|
+
return app, ctx
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cleanup(signum=None, frame=None):
|
|
84
|
+
logging.info("Cleaning up, signal=%s", signum)
|
|
85
|
+
PID_FILE.unlink(missing_ok=True)
|
|
86
|
+
sys.exit(0)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_dir(cli_value: str | None, env_var: str | None, fallback: Path) -> Path:
|
|
90
|
+
if cli_value:
|
|
91
|
+
return Path(cli_value).expanduser().resolve()
|
|
92
|
+
if env_var:
|
|
93
|
+
return Path(env_var).expanduser().resolve()
|
|
94
|
+
return fallback
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_args() -> argparse.Namespace:
|
|
98
|
+
parser = argparse.ArgumentParser(description="AxiControl Flask server")
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--dev",
|
|
101
|
+
action="store_true",
|
|
102
|
+
help="Skip hardware connection checks for local development",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--artwork-root",
|
|
106
|
+
default=None,
|
|
107
|
+
help="Root directory for artwork SVGs (overrides ARTWORK_ROOT).",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--config-dir",
|
|
111
|
+
default=None,
|
|
112
|
+
help="Directory to store configuration (overrides AXI_CONTROL_CONFIG_DIR).",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--log-dir",
|
|
116
|
+
default=None,
|
|
117
|
+
help="Directory to store logs (overrides AXI_CONTROL_LOG_DIR).",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--srv-options",
|
|
121
|
+
default=None,
|
|
122
|
+
help="Path to srv_options.json (overrides AXI_CONTROL_SRV_OPTIONS).",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--host",
|
|
126
|
+
default="0.0.0.0",
|
|
127
|
+
help="Host to bind the server.",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--port",
|
|
131
|
+
default=5050,
|
|
132
|
+
type=int,
|
|
133
|
+
help="Port to bind the server.",
|
|
134
|
+
)
|
|
135
|
+
return parser.parse_args()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
SKIP_LOG_PATHS = {}
|
|
139
|
+
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
|
140
|
+
ACCESS_LOGGER = logging.getLogger("axi_access")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main() -> None:
|
|
144
|
+
args = _parse_args()
|
|
145
|
+
|
|
146
|
+
config_dir = _resolve_dir(
|
|
147
|
+
args.config_dir,
|
|
148
|
+
os.environ.get("AXI_CONTROL_CONFIG_DIR"),
|
|
149
|
+
Path(platformdirs.user_config_dir(APP_NAME, APP_AUTHOR)),
|
|
150
|
+
)
|
|
151
|
+
log_dir = _resolve_dir(
|
|
152
|
+
args.log_dir,
|
|
153
|
+
os.environ.get("AXI_CONTROL_LOG_DIR"),
|
|
154
|
+
Path(platformdirs.user_log_dir(APP_NAME, APP_AUTHOR)),
|
|
155
|
+
)
|
|
156
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
|
|
159
|
+
srv_opts_path = args.srv_options or os.environ.get("AXI_CONTROL_SRV_OPTIONS")
|
|
160
|
+
if srv_opts_path:
|
|
161
|
+
srv_opts_path = Path(srv_opts_path).expanduser().resolve()
|
|
162
|
+
else:
|
|
163
|
+
srv_opts_path = config_dir / "srv_options.json"
|
|
164
|
+
|
|
165
|
+
axc.utils.ensure_srv_options(srv_opts_path)
|
|
166
|
+
|
|
167
|
+
log_filename = log_dir / "axiserver.log"
|
|
168
|
+
plot_status_log_filename = log_dir / "plot_status.log"
|
|
169
|
+
|
|
170
|
+
logging.basicConfig(
|
|
171
|
+
filename=log_filename,
|
|
172
|
+
filemode="a",
|
|
173
|
+
level=logging.INFO,
|
|
174
|
+
format="%(asctime)s %(levelname)s: %(message)s",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
app, ctx = create_app(args.artwork_root, srv_opts_path, log_filename, plot_status_log_filename)
|
|
178
|
+
logging.info("Artwork root set to %s", ctx.file_root)
|
|
179
|
+
logging.info("Using srv_options at %s", srv_opts_path)
|
|
180
|
+
logging.info("Logs directory set to %s", log_dir)
|
|
181
|
+
|
|
182
|
+
if not args.dev:
|
|
183
|
+
if not ctx.ad.connect():
|
|
184
|
+
logging.error("AxiController connection failed")
|
|
185
|
+
raise RuntimeError("AxiController connection failed")
|
|
186
|
+
|
|
187
|
+
PID_FILE.write_text(str(os.getpid()))
|
|
188
|
+
|
|
189
|
+
signal.signal(signal.SIGTERM, cleanup)
|
|
190
|
+
signal.signal(signal.SIGINT, cleanup)
|
|
191
|
+
|
|
192
|
+
app.run(host=args.host, port=args.port, threaded=True)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
main()
|