dsh-labscript 0.2.0 → 0.2.2
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.
- package/CHANGELOG.md +43 -19
- package/LICENSE +21 -21
- package/README.md +167 -154
- package/cordis.patch.yml +6 -6
- package/docs/INSTALL.zh.md +64 -54
- package/docs/README.zh.md +57 -57
- package/examples/config.example.yml +33 -33
- package/examples/postprocess_demo.py +19 -19
- package/examples/protocols/demo_scan.yaml +18 -18
- package/lib/client.js +193 -193
- package/lib/index.js +138 -138
- package/lib/protocols.js +112 -112
- package/lib/runtime.js +350 -326
- package/lib/skill.js +60 -60
- package/lib/store.mjs +136 -136
- package/lib/tools.js +457 -432
- package/package.json +69 -69
- package/python/bridge.py +1506 -1445
- package/python/tests/run_tests.py +364 -239
package/lib/index.js
CHANGED
|
@@ -1,138 +1,138 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-labscript -- host half.
|
|
3
|
-
*
|
|
4
|
-
* Registers the labs_* tools, the runtime skill, and a small HTTP API used by
|
|
5
|
-
* the browser configuration page. The browser half is optional: the tools work
|
|
6
|
-
* without it, and the page only edits the four connection settings (backend,
|
|
7
|
-
* python, runmanager host/port, protocolsDir).
|
|
8
|
-
*/
|
|
9
|
-
import fs from 'node:fs'
|
|
10
|
-
import { createRuntime, LabError } from './runtime.js'
|
|
11
|
-
import { registerTools } from './tools.js'
|
|
12
|
-
import { registerSkill } from './skill.js'
|
|
13
|
-
import { writeUiConfig } from './store.mjs'
|
|
14
|
-
|
|
15
|
-
export const name = 'dsh-labscript'
|
|
16
|
-
export const inject = ['tools', 'skills']
|
|
17
|
-
|
|
18
|
-
const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
19
|
-
const API_PREFIX = '/dsh-labscript/api'
|
|
20
|
-
const MAX_BODY_BYTES = 64 * 1024
|
|
21
|
-
|
|
22
|
-
function sendJson(response, status, body) {
|
|
23
|
-
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
24
|
-
response.end(JSON.stringify(body))
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function readJsonBody(request) {
|
|
28
|
-
return new Promise((resolve, reject) => {
|
|
29
|
-
const chunks = []
|
|
30
|
-
let size = 0
|
|
31
|
-
request.on('data', (chunk) => {
|
|
32
|
-
size += chunk.length
|
|
33
|
-
if (size > MAX_BODY_BYTES) {
|
|
34
|
-
reject(new Error('request body too large'))
|
|
35
|
-
request.destroy()
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
chunks.push(chunk)
|
|
39
|
-
})
|
|
40
|
-
request.on('end', () => {
|
|
41
|
-
try {
|
|
42
|
-
resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
43
|
-
} catch (error) {
|
|
44
|
-
reject(new Error('request body is not JSON'))
|
|
45
|
-
}
|
|
46
|
-
})
|
|
47
|
-
request.on('error', reject)
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function isTrustedRequest(ctx, request) {
|
|
52
|
-
let connection
|
|
53
|
-
try {
|
|
54
|
-
connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
|
|
55
|
-
} catch {
|
|
56
|
-
connection = undefined
|
|
57
|
-
}
|
|
58
|
-
if (connection === undefined) return false
|
|
59
|
-
try {
|
|
60
|
-
if (typeof connection.isTrusted === 'function' && connection.isTrusted(request) === true) return true
|
|
61
|
-
if (typeof connection.requestRejection === 'function') {
|
|
62
|
-
const code = connection.requestRejection(request)
|
|
63
|
-
return code === undefined || code === null || code === false
|
|
64
|
-
}
|
|
65
|
-
} catch {
|
|
66
|
-
/* fall through to reject */
|
|
67
|
-
}
|
|
68
|
-
return false
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function registerRoutes(ctx, runtime) {
|
|
72
|
-
const webServer = typeof ctx.get === 'function' ? ctx.get('webServer') : ctx.webServer
|
|
73
|
-
if (!webServer || typeof webServer.register !== 'function') return
|
|
74
|
-
const handler = async (request, response) => {
|
|
75
|
-
const url = new URL(request.url || '/', 'http://127.0.0.1')
|
|
76
|
-
if (!url.pathname.startsWith(API_PREFIX)) {
|
|
77
|
-
sendJson(response, 404, { ok: false, error: 'not found' })
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
if (!isTrustedRequest(ctx, request)) {
|
|
81
|
-
sendJson(response, 403, { ok: false, error: 'untrusted request origin' })
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
const route = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/'
|
|
85
|
-
try {
|
|
86
|
-
if (route === '/config' && request.method === 'GET') {
|
|
87
|
-
const config = runtime.config
|
|
88
|
-
const doctorInfo = { configFile: runtime.uiConfigPath(), stateFile: runtime.statePath(), python: config.python }
|
|
89
|
-
sendJson(response, 200, {
|
|
90
|
-
ok: true,
|
|
91
|
-
version: runtime.version,
|
|
92
|
-
packageName: PACKAGE.name,
|
|
93
|
-
...doctorInfo,
|
|
94
|
-
config: {
|
|
95
|
-
backend: config.backend,
|
|
96
|
-
python: config.python,
|
|
97
|
-
runmanager: config.runmanager,
|
|
98
|
-
protocolsDir: config.protocolsDir,
|
|
99
|
-
},
|
|
100
|
-
})
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
if (route === '/config' && request.method === 'POST') {
|
|
104
|
-
const body = await readJsonBody(request)
|
|
105
|
-
const stored = writeUiConfig(body, runtime.uiHome)
|
|
106
|
-
sendJson(response, 200, { ok: true, config: stored })
|
|
107
|
-
return
|
|
108
|
-
}
|
|
109
|
-
if (route === '/doctor' && request.method === 'POST') {
|
|
110
|
-
const result = await runtime.doctor()
|
|
111
|
-
sendJson(response, 200, { ok: true, ...result })
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
sendJson(response, 404, { ok: false, error: 'not found' })
|
|
115
|
-
} catch (error) {
|
|
116
|
-
sendJson(response, 400, { ok: false, error: error && error.message ? error.message : String(error) })
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
ctx.effect(() => webServer.register({ kind: 'prefix', path: API_PREFIX, handler }))
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function apply(ctx, config) {
|
|
123
|
-
const runtime = createRuntime(ctx, config)
|
|
124
|
-
registerTools(ctx, runtime)
|
|
125
|
-
registerSkill(ctx)
|
|
126
|
-
registerRoutes(ctx, runtime)
|
|
127
|
-
const logger = ctx.logger
|
|
128
|
-
if (logger && typeof logger.info === 'function') {
|
|
129
|
-
const current = runtime.config
|
|
130
|
-
logger.info('[dsh-labscript] v' + runtime.version + ' backend=' + current.backend + ' python=' + (current.python || '<unset>'))
|
|
131
|
-
if (current.backend === 'labscript' && !current.python) {
|
|
132
|
-
logger.warn('[dsh-labscript] backend is labscript but config.python is empty; every hardware tool will fail')
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return runtime
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export { LabError }
|
|
1
|
+
/**
|
|
2
|
+
* dsh-labscript -- host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers the labs_* tools, the runtime skill, and a small HTTP API used by
|
|
5
|
+
* the browser configuration page. The browser half is optional: the tools work
|
|
6
|
+
* without it, and the page only edits the four connection settings (backend,
|
|
7
|
+
* python, runmanager host/port, protocolsDir).
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import { createRuntime, LabError } from './runtime.js'
|
|
11
|
+
import { registerTools } from './tools.js'
|
|
12
|
+
import { registerSkill } from './skill.js'
|
|
13
|
+
import { writeUiConfig } from './store.mjs'
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-labscript'
|
|
16
|
+
export const inject = ['tools', 'skills']
|
|
17
|
+
|
|
18
|
+
const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
19
|
+
const API_PREFIX = '/dsh-labscript/api'
|
|
20
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
21
|
+
|
|
22
|
+
function sendJson(response, status, body) {
|
|
23
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
24
|
+
response.end(JSON.stringify(body))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readJsonBody(request) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const chunks = []
|
|
30
|
+
let size = 0
|
|
31
|
+
request.on('data', (chunk) => {
|
|
32
|
+
size += chunk.length
|
|
33
|
+
if (size > MAX_BODY_BYTES) {
|
|
34
|
+
reject(new Error('request body too large'))
|
|
35
|
+
request.destroy()
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
chunks.push(chunk)
|
|
39
|
+
})
|
|
40
|
+
request.on('end', () => {
|
|
41
|
+
try {
|
|
42
|
+
resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
43
|
+
} catch (error) {
|
|
44
|
+
reject(new Error('request body is not JSON'))
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
request.on('error', reject)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isTrustedRequest(ctx, request) {
|
|
52
|
+
let connection
|
|
53
|
+
try {
|
|
54
|
+
connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
|
|
55
|
+
} catch {
|
|
56
|
+
connection = undefined
|
|
57
|
+
}
|
|
58
|
+
if (connection === undefined) return false
|
|
59
|
+
try {
|
|
60
|
+
if (typeof connection.isTrusted === 'function' && connection.isTrusted(request) === true) return true
|
|
61
|
+
if (typeof connection.requestRejection === 'function') {
|
|
62
|
+
const code = connection.requestRejection(request)
|
|
63
|
+
return code === undefined || code === null || code === false
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
/* fall through to reject */
|
|
67
|
+
}
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function registerRoutes(ctx, runtime) {
|
|
72
|
+
const webServer = typeof ctx.get === 'function' ? ctx.get('webServer') : ctx.webServer
|
|
73
|
+
if (!webServer || typeof webServer.register !== 'function') return
|
|
74
|
+
const handler = async (request, response) => {
|
|
75
|
+
const url = new URL(request.url || '/', 'http://127.0.0.1')
|
|
76
|
+
if (!url.pathname.startsWith(API_PREFIX)) {
|
|
77
|
+
sendJson(response, 404, { ok: false, error: 'not found' })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (!isTrustedRequest(ctx, request)) {
|
|
81
|
+
sendJson(response, 403, { ok: false, error: 'untrusted request origin' })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const route = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/'
|
|
85
|
+
try {
|
|
86
|
+
if (route === '/config' && request.method === 'GET') {
|
|
87
|
+
const config = runtime.config
|
|
88
|
+
const doctorInfo = { configFile: runtime.uiConfigPath(), stateFile: runtime.statePath(), python: config.python }
|
|
89
|
+
sendJson(response, 200, {
|
|
90
|
+
ok: true,
|
|
91
|
+
version: runtime.version,
|
|
92
|
+
packageName: PACKAGE.name,
|
|
93
|
+
...doctorInfo,
|
|
94
|
+
config: {
|
|
95
|
+
backend: config.backend,
|
|
96
|
+
python: config.python,
|
|
97
|
+
runmanager: config.runmanager,
|
|
98
|
+
protocolsDir: config.protocolsDir,
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (route === '/config' && request.method === 'POST') {
|
|
104
|
+
const body = await readJsonBody(request)
|
|
105
|
+
const stored = writeUiConfig(body, runtime.uiHome)
|
|
106
|
+
sendJson(response, 200, { ok: true, config: stored })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (route === '/doctor' && request.method === 'POST') {
|
|
110
|
+
const result = await runtime.doctor()
|
|
111
|
+
sendJson(response, 200, { ok: true, ...result })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
sendJson(response, 404, { ok: false, error: 'not found' })
|
|
115
|
+
} catch (error) {
|
|
116
|
+
sendJson(response, 400, { ok: false, error: error && error.message ? error.message : String(error) })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
ctx.effect(() => webServer.register({ kind: 'prefix', path: API_PREFIX, handler }))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function apply(ctx, config) {
|
|
123
|
+
const runtime = createRuntime(ctx, config)
|
|
124
|
+
registerTools(ctx, runtime)
|
|
125
|
+
registerSkill(ctx)
|
|
126
|
+
registerRoutes(ctx, runtime)
|
|
127
|
+
const logger = ctx.logger
|
|
128
|
+
if (logger && typeof logger.info === 'function') {
|
|
129
|
+
const current = runtime.config
|
|
130
|
+
logger.info('[dsh-labscript] v' + runtime.version + ' backend=' + current.backend + ' python=' + (current.python || '<unset>'))
|
|
131
|
+
if (current.backend === 'labscript' && !current.python) {
|
|
132
|
+
logger.warn('[dsh-labscript] backend is labscript but config.python is empty; every hardware tool will fail')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return runtime
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export { LabError }
|
package/lib/protocols.js
CHANGED
|
@@ -1,112 +1,112 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Protocol files: discovery, YAML parsing, name resolution.
|
|
3
|
-
*
|
|
4
|
-
* A protocol is a YAML mapping. The schema is deliberately small: name,
|
|
5
|
-
* labscript (or joint_script), set, scan, reps, engage, postprocess,
|
|
6
|
-
* finish.restore/panel/history. Unknown keys are reported as warnings; the
|
|
7
|
-
* legacy key "analysis" is accepted as an alias for "postprocess".
|
|
8
|
-
*/
|
|
9
|
-
import fs from 'node:fs'
|
|
10
|
-
import path from 'node:path'
|
|
11
|
-
import YAML from 'yaml'
|
|
12
|
-
|
|
13
|
-
const KNOWN_KEYS = new Set(['name', 'labscript', 'joint_script', 'set', 'scan', 'reps', 'engage', 'postprocess', 'finish', 'analysis'])
|
|
14
|
-
const MAX_PROTOCOL_BYTES = 256 * 1024
|
|
15
|
-
|
|
16
|
-
function protocolFiles(dir) {
|
|
17
|
-
if (!dir) return []
|
|
18
|
-
let entries = []
|
|
19
|
-
try {
|
|
20
|
-
entries = fs.readdirSync(dir)
|
|
21
|
-
} catch (error) {
|
|
22
|
-
return []
|
|
23
|
-
}
|
|
24
|
-
return entries
|
|
25
|
-
.filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
|
|
26
|
-
.sort()
|
|
27
|
-
.map((name) => path.join(dir, name))
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function listProtocols(dir) {
|
|
31
|
-
return protocolFiles(dir).map((file) => {
|
|
32
|
-
try {
|
|
33
|
-
const loaded = loadProtocolFile(file)
|
|
34
|
-
return { name: loaded.name, file: path.basename(file), warnings: loaded.warnings }
|
|
35
|
-
} catch (error) {
|
|
36
|
-
return { name: path.basename(file), file: path.basename(file), error: error.message }
|
|
37
|
-
}
|
|
38
|
-
})
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function resolveProtocolPath(dir, ref) {
|
|
42
|
-
const value = String(ref || '').trim()
|
|
43
|
-
if (!value) throw new Error('protocol name is required')
|
|
44
|
-
if (path.isAbsolute(value)) {
|
|
45
|
-
if (fs.existsSync(value)) return value
|
|
46
|
-
throw new Error('protocol file not found: ' + value)
|
|
47
|
-
}
|
|
48
|
-
const candidates = [path.join(dir || '', value)]
|
|
49
|
-
if (!value.endsWith('.yaml') && !value.endsWith('.yml')) {
|
|
50
|
-
candidates.push(path.join(dir || '', value + '.yaml'))
|
|
51
|
-
candidates.push(path.join(dir || '', value + '.yml'))
|
|
52
|
-
}
|
|
53
|
-
for (const candidate of candidates) {
|
|
54
|
-
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
|
|
55
|
-
}
|
|
56
|
-
throw new Error('protocol not found in protocolsDir: ' + value)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function loadProtocol(dir, ref) {
|
|
60
|
-
return loadProtocolFile(resolveProtocolPath(dir, ref))
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function loadProtocolFile(file) {
|
|
64
|
-
const stat = fs.statSync(file)
|
|
65
|
-
if (stat.size > MAX_PROTOCOL_BYTES) throw new Error('protocol file is too large: ' + file)
|
|
66
|
-
const text = fs.readFileSync(file, 'utf8')
|
|
67
|
-
let data
|
|
68
|
-
try {
|
|
69
|
-
data = YAML.parse(text)
|
|
70
|
-
} catch (error) {
|
|
71
|
-
throw new Error('cannot parse protocol YAML ' + path.basename(file) + ': ' + error.message)
|
|
72
|
-
}
|
|
73
|
-
return normalizeProtocol(data, file)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function normalizeProtocol(data, file) {
|
|
77
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
78
|
-
throw new Error('protocol must be a YAML mapping: ' + (file || 'inline'))
|
|
79
|
-
}
|
|
80
|
-
const warnings = []
|
|
81
|
-
for (const key of Object.keys(data)) {
|
|
82
|
-
if (!KNOWN_KEYS.has(key)) warnings.push('unknown protocol key ignored: ' + key)
|
|
83
|
-
}
|
|
84
|
-
const plan = {
|
|
85
|
-
name: data.name ? String(data.name) : path.basename(file || 'protocol').replace(/\.ya?ml$/, ''),
|
|
86
|
-
labscript: data.labscript ? String(data.labscript) : undefined,
|
|
87
|
-
joint_script: data.joint_script ? String(data.joint_script) : undefined,
|
|
88
|
-
set: data.set && typeof data.set === 'object' ? Object.assign({}, data.set) : {},
|
|
89
|
-
scan: data.scan && typeof data.scan === 'object' ? Object.assign({}, data.scan) : {},
|
|
90
|
-
reps: data.reps === undefined ? 1 : data.reps,
|
|
91
|
-
engage: data.engage === undefined ? true : Boolean(data.engage),
|
|
92
|
-
postprocess: null,
|
|
93
|
-
finish: { restore: true, panel: {}, history: {} },
|
|
94
|
-
}
|
|
95
|
-
if (data.analysis !== undefined && data.postprocess === undefined) {
|
|
96
|
-
plan.postprocess = data.analysis ? String(data.analysis) : null
|
|
97
|
-
warnings.push('legacy key "analysis" used as "postprocess"')
|
|
98
|
-
} else if (data.postprocess !== undefined) {
|
|
99
|
-
plan.postprocess = data.postprocess ? String(data.postprocess) : null
|
|
100
|
-
}
|
|
101
|
-
if (data.finish && typeof data.finish === 'object') {
|
|
102
|
-
plan.finish.restore = data.finish.restore === undefined ? true : Boolean(data.finish.restore)
|
|
103
|
-
if (data.finish.panel && typeof data.finish.panel === 'object') plan.finish.panel = Object.assign({}, data.finish.panel)
|
|
104
|
-
if (data.finish.history && typeof data.finish.history === 'object') plan.finish.history = Object.assign({}, data.finish.history)
|
|
105
|
-
for (const key of Object.keys(data.finish)) {
|
|
106
|
-
if (!['restore', 'panel', 'history'].includes(key)) warnings.push('unknown finish key ignored: ' + key)
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
if (!plan.labscript && !plan.joint_script) throw new Error('protocol needs "labscript" or "joint_script": ' + (file || 'inline'))
|
|
110
|
-
if (!Number.isInteger(plan.reps) || plan.reps < 1) throw new Error('protocol reps must be a positive integer: ' + (file || 'inline'))
|
|
111
|
-
return { name: plan.name, file: file || null, plan, warnings }
|
|
112
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Protocol files: discovery, YAML parsing, name resolution.
|
|
3
|
+
*
|
|
4
|
+
* A protocol is a YAML mapping. The schema is deliberately small: name,
|
|
5
|
+
* labscript (or joint_script), set, scan, reps, engage, postprocess,
|
|
6
|
+
* finish.restore/panel/history. Unknown keys are reported as warnings; the
|
|
7
|
+
* legacy key "analysis" is accepted as an alias for "postprocess".
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import YAML from 'yaml'
|
|
12
|
+
|
|
13
|
+
const KNOWN_KEYS = new Set(['name', 'labscript', 'joint_script', 'set', 'scan', 'reps', 'engage', 'postprocess', 'finish', 'analysis'])
|
|
14
|
+
const MAX_PROTOCOL_BYTES = 256 * 1024
|
|
15
|
+
|
|
16
|
+
function protocolFiles(dir) {
|
|
17
|
+
if (!dir) return []
|
|
18
|
+
let entries = []
|
|
19
|
+
try {
|
|
20
|
+
entries = fs.readdirSync(dir)
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
return entries
|
|
25
|
+
.filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
|
|
26
|
+
.sort()
|
|
27
|
+
.map((name) => path.join(dir, name))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function listProtocols(dir) {
|
|
31
|
+
return protocolFiles(dir).map((file) => {
|
|
32
|
+
try {
|
|
33
|
+
const loaded = loadProtocolFile(file)
|
|
34
|
+
return { name: loaded.name, file: path.basename(file), warnings: loaded.warnings }
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return { name: path.basename(file), file: path.basename(file), error: error.message }
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveProtocolPath(dir, ref) {
|
|
42
|
+
const value = String(ref || '').trim()
|
|
43
|
+
if (!value) throw new Error('protocol name is required')
|
|
44
|
+
if (path.isAbsolute(value)) {
|
|
45
|
+
if (fs.existsSync(value)) return value
|
|
46
|
+
throw new Error('protocol file not found: ' + value)
|
|
47
|
+
}
|
|
48
|
+
const candidates = [path.join(dir || '', value)]
|
|
49
|
+
if (!value.endsWith('.yaml') && !value.endsWith('.yml')) {
|
|
50
|
+
candidates.push(path.join(dir || '', value + '.yaml'))
|
|
51
|
+
candidates.push(path.join(dir || '', value + '.yml'))
|
|
52
|
+
}
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
|
|
55
|
+
}
|
|
56
|
+
throw new Error('protocol not found in protocolsDir: ' + value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadProtocol(dir, ref) {
|
|
60
|
+
return loadProtocolFile(resolveProtocolPath(dir, ref))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadProtocolFile(file) {
|
|
64
|
+
const stat = fs.statSync(file)
|
|
65
|
+
if (stat.size > MAX_PROTOCOL_BYTES) throw new Error('protocol file is too large: ' + file)
|
|
66
|
+
const text = fs.readFileSync(file, 'utf8')
|
|
67
|
+
let data
|
|
68
|
+
try {
|
|
69
|
+
data = YAML.parse(text)
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw new Error('cannot parse protocol YAML ' + path.basename(file) + ': ' + error.message)
|
|
72
|
+
}
|
|
73
|
+
return normalizeProtocol(data, file)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeProtocol(data, file) {
|
|
77
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
78
|
+
throw new Error('protocol must be a YAML mapping: ' + (file || 'inline'))
|
|
79
|
+
}
|
|
80
|
+
const warnings = []
|
|
81
|
+
for (const key of Object.keys(data)) {
|
|
82
|
+
if (!KNOWN_KEYS.has(key)) warnings.push('unknown protocol key ignored: ' + key)
|
|
83
|
+
}
|
|
84
|
+
const plan = {
|
|
85
|
+
name: data.name ? String(data.name) : path.basename(file || 'protocol').replace(/\.ya?ml$/, ''),
|
|
86
|
+
labscript: data.labscript ? String(data.labscript) : undefined,
|
|
87
|
+
joint_script: data.joint_script ? String(data.joint_script) : undefined,
|
|
88
|
+
set: data.set && typeof data.set === 'object' ? Object.assign({}, data.set) : {},
|
|
89
|
+
scan: data.scan && typeof data.scan === 'object' ? Object.assign({}, data.scan) : {},
|
|
90
|
+
reps: data.reps === undefined ? 1 : data.reps,
|
|
91
|
+
engage: data.engage === undefined ? true : Boolean(data.engage),
|
|
92
|
+
postprocess: null,
|
|
93
|
+
finish: { restore: true, panel: {}, history: {} },
|
|
94
|
+
}
|
|
95
|
+
if (data.analysis !== undefined && data.postprocess === undefined) {
|
|
96
|
+
plan.postprocess = data.analysis ? String(data.analysis) : null
|
|
97
|
+
warnings.push('legacy key "analysis" used as "postprocess"')
|
|
98
|
+
} else if (data.postprocess !== undefined) {
|
|
99
|
+
plan.postprocess = data.postprocess ? String(data.postprocess) : null
|
|
100
|
+
}
|
|
101
|
+
if (data.finish && typeof data.finish === 'object') {
|
|
102
|
+
plan.finish.restore = data.finish.restore === undefined ? true : Boolean(data.finish.restore)
|
|
103
|
+
if (data.finish.panel && typeof data.finish.panel === 'object') plan.finish.panel = Object.assign({}, data.finish.panel)
|
|
104
|
+
if (data.finish.history && typeof data.finish.history === 'object') plan.finish.history = Object.assign({}, data.finish.history)
|
|
105
|
+
for (const key of Object.keys(data.finish)) {
|
|
106
|
+
if (!['restore', 'panel', 'history'].includes(key)) warnings.push('unknown finish key ignored: ' + key)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!plan.labscript && !plan.joint_script) throw new Error('protocol needs "labscript" or "joint_script": ' + (file || 'inline'))
|
|
110
|
+
if (!Number.isInteger(plan.reps) || plan.reps < 1) throw new Error('protocol reps must be a positive integer: ' + (file || 'inline'))
|
|
111
|
+
return { name: plan.name, file: file || null, plan, warnings }
|
|
112
|
+
}
|