@vention/vention-skills 0.1.0
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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +61 -0
- package/mcp.json +9 -0
- package/package.json +19 -0
- package/plugin.json +12 -0
- package/skills/vention-design/SKILL.md +522 -0
- package/skills/vention-machine-logic/SKILL.md +256 -0
- package/skills/vention-machine-logic/examples/conveyor-with-sensor/main.py +58 -0
- package/skills/vention-machine-logic/examples/homing-and-indexing/main.py +51 -0
- package/skills/vention-machine-logic/examples/parallel-conveyor-with-recipe/main.py +118 -0
- package/skills/vention-machine-logic/examples/parallel-conveyor-with-recipe/requirements.txt +1 -0
- package/skills/vention-machine-logic/examples/pick-and-place-state-machine/main.py +159 -0
- package/skills/vention-machine-logic/examples/pick-and-place-state-machine/requirements.txt +2 -0
- package/skills/vention-machine-logic/scripts/library-readme.py +233 -0
- package/skills/vention-machine-logic/scripts/vention-docs.py +176 -0
- package/skills/vention-machine-logic-hmi/SKILL.md +210 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/buf.gen.yaml +5 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/index.html +11 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/package.json +34 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/app.tsx +152 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/client.ts +17 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/main.tsx +16 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/pages/logs-page.tsx +52 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/pages/recipes-page.tsx +139 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/pages/run-page.tsx +65 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/station.ts +34 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/use-stream.ts +54 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/tsconfig.json +24 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/models.py +13 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/project.json +6 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/proto/app.proto +192 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/requirements.txt +5 -0
- package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/server.py +242 -0
- package/skills/vention-monitoring/SKILL.md +82 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Print the published README of a Vention library at the exact version an application pins.
|
|
3
|
+
|
|
4
|
+
The Vention libraries an application depends on ship their documentation as their package README:
|
|
5
|
+
vention-storage, vention-state-machine and vention-communication on PyPI, @vention/machine-ui,
|
|
6
|
+
@vention/machine-apps-components and @vention/machine-logic-ui-sdk on npm. Reading the pinned
|
|
7
|
+
version's README beats recalling an API that moved between releases.
|
|
8
|
+
|
|
9
|
+
A range is not a version: a README is printed only for an exact pin, and anything else is reported
|
|
10
|
+
as unavailable rather than answered from the newest release.
|
|
11
|
+
|
|
12
|
+
python3 library-readme.py --from-app .
|
|
13
|
+
python3 library-readme.py --pypi vention-storage 0.6.48
|
|
14
|
+
python3 library-readme.py --npm @vention/machine-ui 5.0.38
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import http.client
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import tarfile
|
|
25
|
+
import urllib.request
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import IO
|
|
29
|
+
from urllib.parse import urlsplit
|
|
30
|
+
|
|
31
|
+
PYPI_REGISTRY = "https://pypi.org"
|
|
32
|
+
NPM_REGISTRY = "https://registry.npmjs.org"
|
|
33
|
+
TIMEOUT = 20
|
|
34
|
+
ALLOWED_SCHEMES = frozenset({"http", "https", "file"})
|
|
35
|
+
|
|
36
|
+
PYPI_LIBRARIES = ("vention-state-machine", "vention-storage", "vention-communication")
|
|
37
|
+
NPM_LIBRARIES = ("@vention/machine-ui", "@vention/machine-apps-components", "@vention/machine-logic-ui-sdk")
|
|
38
|
+
|
|
39
|
+
REQUIREMENT_PATTERN = re.compile(
|
|
40
|
+
r"^\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?P<operator>[=<>!~]+)?\s*(?P<version>[^\s;#,]*)"
|
|
41
|
+
)
|
|
42
|
+
EXACT_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+[\w.+-]*$")
|
|
43
|
+
NAME_SEPARATOR_PATTERN = re.compile(r"[-_.]+")
|
|
44
|
+
PACKAGE_README = "package/readme.md"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Unavailable(Exception):
|
|
48
|
+
"""A README that cannot be produced, carrying the reason a reader needs."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Pin:
|
|
53
|
+
name: str
|
|
54
|
+
version: str
|
|
55
|
+
registry: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def open_url(url: str) -> IO[bytes]:
|
|
59
|
+
scheme = urlsplit(url).scheme
|
|
60
|
+
if scheme not in ALLOWED_SCHEMES:
|
|
61
|
+
raise Unavailable(f"unsupported url scheme {scheme}")
|
|
62
|
+
response: IO[bytes] = urllib.request.urlopen(url, timeout=TIMEOUT) # noqa: S310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
|
63
|
+
|
|
64
|
+
return response
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fetch_bytes(url: str) -> bytes:
|
|
68
|
+
try:
|
|
69
|
+
with open_url(url) as response:
|
|
70
|
+
return response.read()
|
|
71
|
+
except (OSError, http.client.HTTPException, ValueError) as error:
|
|
72
|
+
raise Unavailable(f"cannot reach {url}: {error}") from error
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_json(url: str) -> dict[str, object]:
|
|
76
|
+
try:
|
|
77
|
+
payload = json.loads(fetch_bytes(url))
|
|
78
|
+
except json.JSONDecodeError as error:
|
|
79
|
+
raise Unavailable(f"{url} did not answer with JSON: {error}") from error
|
|
80
|
+
if not isinstance(payload, dict):
|
|
81
|
+
raise Unavailable(f"{url} did not answer with a JSON object")
|
|
82
|
+
return payload
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def pypi_readme(registry: str, name: str, version: str) -> str:
|
|
86
|
+
url = f"{registry}/pypi/{name}/{version}/json"
|
|
87
|
+
info = fetch_json(url).get("info")
|
|
88
|
+
description = info.get("description") if isinstance(info, dict) else None
|
|
89
|
+
if not isinstance(description, str) or description.strip() == "":
|
|
90
|
+
raise Unavailable(f"{name} {version} publishes no description on PyPI")
|
|
91
|
+
return description
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def npm_readme(registry: str, name: str, version: str) -> str:
|
|
95
|
+
url = f"{registry}/{name.replace('/', '%2F')}"
|
|
96
|
+
releases = fetch_json(url).get("versions")
|
|
97
|
+
release = releases.get(version) if isinstance(releases, dict) else None
|
|
98
|
+
if not isinstance(release, dict):
|
|
99
|
+
raise Unavailable(f"{url} publishes no {name}@{version}")
|
|
100
|
+
distribution = release.get("dist")
|
|
101
|
+
tarball = distribution.get("tarball") if isinstance(distribution, dict) else None
|
|
102
|
+
if not isinstance(tarball, str):
|
|
103
|
+
raise Unavailable(f"{name}@{version} publishes no tarball")
|
|
104
|
+
return readme_from_tarball(tarball)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def readme_from_tarball(url: str) -> str:
|
|
108
|
+
"""The README from inside a published npm tarball, streamed rather than written to disk."""
|
|
109
|
+
try:
|
|
110
|
+
with open_url(url) as response:
|
|
111
|
+
with tarfile.open(fileobj=response, mode="r|gz") as archive:
|
|
112
|
+
for member in archive:
|
|
113
|
+
if not member.isfile() or member.name.lower().removeprefix("./") != PACKAGE_README:
|
|
114
|
+
continue
|
|
115
|
+
content = archive.extractfile(member)
|
|
116
|
+
if content is not None:
|
|
117
|
+
readme = content.read().decode("utf-8", errors="replace")
|
|
118
|
+
if readme.strip() == "":
|
|
119
|
+
raise Unavailable(f"{url} publishes an empty README")
|
|
120
|
+
return readme
|
|
121
|
+
except (OSError, http.client.HTTPException, ValueError, tarfile.TarError) as error:
|
|
122
|
+
raise Unavailable(f"cannot read {url}: {error}") from error
|
|
123
|
+
raise Unavailable(f"{url} carries no {PACKAGE_README}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def normalised(name: str) -> str:
|
|
127
|
+
"""PEP 503 normalisation: vention_storage, Vention.Storage and vention-storage are one project."""
|
|
128
|
+
return NAME_SEPARATOR_PATTERN.sub("-", name).lower()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def pypi_pins(app_directory: Path) -> tuple[list[Pin], list[str]]:
|
|
132
|
+
"""Exact pins of the Vention PyPI libraries, and a note for each one left unpinned."""
|
|
133
|
+
path = app_directory / "requirements.txt"
|
|
134
|
+
if not path.is_file():
|
|
135
|
+
return [], []
|
|
136
|
+
pins: list[Pin] = []
|
|
137
|
+
notes: list[str] = []
|
|
138
|
+
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
139
|
+
found = REQUIREMENT_PATTERN.match(line)
|
|
140
|
+
if not found or normalised(found.group("name")) not in PYPI_LIBRARIES:
|
|
141
|
+
continue
|
|
142
|
+
name, operator, version = found.group("name"), found.group("operator"), found.group("version")
|
|
143
|
+
if operator == "==" and version:
|
|
144
|
+
pins.append(Pin(normalised(name), version, "pypi"))
|
|
145
|
+
else:
|
|
146
|
+
notes.append(f"{name} in {path} is not pinned to an exact version")
|
|
147
|
+
return pins, notes
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def npm_pins(app_directory: Path) -> tuple[list[Pin], list[str]]:
|
|
151
|
+
"""Exact versions of the Vention npm libraries, and a note for each one declared as a range.
|
|
152
|
+
|
|
153
|
+
The HMI manifest lives in customui/, not in the ui/ folder beside it, which holds only the
|
|
154
|
+
built bundle. mm-execution-engine owns that layout as MachineCodeDirectoryManager's
|
|
155
|
+
CUSTOMUI_FOLDER_NAME, so check there before changing this path.
|
|
156
|
+
"""
|
|
157
|
+
path = app_directory / "customui" / "package.json"
|
|
158
|
+
if not path.is_file():
|
|
159
|
+
return [], []
|
|
160
|
+
try:
|
|
161
|
+
manifest = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
162
|
+
except json.JSONDecodeError as error:
|
|
163
|
+
return [], [f"{path} is not valid JSON: {error}"]
|
|
164
|
+
declared = {**manifest.get("devDependencies", {}), **manifest.get("dependencies", {})}
|
|
165
|
+
pins: list[Pin] = []
|
|
166
|
+
notes: list[str] = []
|
|
167
|
+
for name in NPM_LIBRARIES:
|
|
168
|
+
version = declared.get(name)
|
|
169
|
+
if not isinstance(version, str):
|
|
170
|
+
continue
|
|
171
|
+
if EXACT_VERSION_PATTERN.match(version):
|
|
172
|
+
pins.append(Pin(name, version, "npm"))
|
|
173
|
+
else:
|
|
174
|
+
notes.append(f"{name} in {path} is declared as {version}, which is a range, not a version")
|
|
175
|
+
return pins, notes
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def read_pin(pin: Pin, pypi_registry: str, npm_registry: str) -> str:
|
|
179
|
+
if pin.registry == "pypi":
|
|
180
|
+
return pypi_readme(pypi_registry, pin.name, pin.version)
|
|
181
|
+
return npm_readme(npm_registry, pin.name, pin.version)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def print_app_readmes(app_directory: Path, pypi_registry: str, npm_registry: str) -> int:
|
|
185
|
+
pypi_found, pypi_notes = pypi_pins(app_directory)
|
|
186
|
+
npm_found, npm_notes = npm_pins(app_directory)
|
|
187
|
+
notes = pypi_notes + npm_notes
|
|
188
|
+
printed = 0
|
|
189
|
+
|
|
190
|
+
for pin in pypi_found + npm_found:
|
|
191
|
+
try:
|
|
192
|
+
readme = read_pin(pin, pypi_registry, npm_registry)
|
|
193
|
+
except Unavailable as error:
|
|
194
|
+
notes.append(str(error))
|
|
195
|
+
continue
|
|
196
|
+
print(f"## {pin.name}@{pin.version}\n")
|
|
197
|
+
print(readme.strip())
|
|
198
|
+
print()
|
|
199
|
+
printed += 1
|
|
200
|
+
|
|
201
|
+
for note in notes:
|
|
202
|
+
print(f"unavailable: {note}", file=sys.stderr)
|
|
203
|
+
if printed == 0:
|
|
204
|
+
print(f"unavailable: no Vention library README could be read for {app_directory}", file=sys.stderr)
|
|
205
|
+
return 2
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main() -> int:
|
|
210
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
211
|
+
source = parser.add_mutually_exclusive_group(required=True)
|
|
212
|
+
source.add_argument("--from-app", metavar="DIR", help="read every Vention library pinned by an application directory")
|
|
213
|
+
source.add_argument("--pypi", nargs=2, metavar=("NAME", "VERSION"), help="one PyPI package at one version")
|
|
214
|
+
source.add_argument("--npm", nargs=2, metavar=("NAME", "VERSION"), help="one npm package at one version")
|
|
215
|
+
parser.add_argument("--pypi-registry-url", default=PYPI_REGISTRY, help=f"PyPI base URL (default: {PYPI_REGISTRY})")
|
|
216
|
+
parser.add_argument("--npm-registry-url", default=NPM_REGISTRY, help=f"npm registry base URL (default: {NPM_REGISTRY})")
|
|
217
|
+
arguments = parser.parse_args()
|
|
218
|
+
|
|
219
|
+
if arguments.from_app:
|
|
220
|
+
return print_app_readmes(Path(arguments.from_app), arguments.pypi_registry_url, arguments.npm_registry_url)
|
|
221
|
+
|
|
222
|
+
name, version = arguments.pypi or arguments.npm
|
|
223
|
+
registry = "pypi" if arguments.pypi else "npm"
|
|
224
|
+
try:
|
|
225
|
+
print(read_pin(Pin(name, version, registry), arguments.pypi_registry_url, arguments.npm_registry_url).strip())
|
|
226
|
+
except Unavailable as error:
|
|
227
|
+
print(f"unavailable: {error}", file=sys.stderr)
|
|
228
|
+
return 2
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolve the Vention documentation page for one machine-logic-sdk version.
|
|
3
|
+
|
|
4
|
+
Ported from the delivery team's plugins/delivery-pipeline/pipeline/vention-docs.py in
|
|
5
|
+
delivery-team-standards; this copy is now the canonical one.
|
|
6
|
+
|
|
7
|
+
Three SDK majors are documented at once and an application pins one, so handing an author the wrong
|
|
8
|
+
version's signatures produces confident code that compiles against nothing. docs.vention.com
|
|
9
|
+
publishes llms.txt (an index of every page) and a .md twin of each page, but no search API, so the
|
|
10
|
+
version is matched against index titles and the page is fetched live.
|
|
11
|
+
|
|
12
|
+
Index entries carry a description after the closing parenthesis. Those descriptions are stale --
|
|
13
|
+
the v3.1.x entry describes version 2.0.x -- so nothing here reads them. Titles only.
|
|
14
|
+
|
|
15
|
+
A pinned version with no matching page fails loudly rather than serving a different version's page,
|
|
16
|
+
and so does a fetched page whose body does not name the series its index title claims.
|
|
17
|
+
|
|
18
|
+
python3 vention-docs.py --version 3.1.0 --print-page
|
|
19
|
+
python3 vention-docs.py --from-requirements requirements.txt --print-url
|
|
20
|
+
python3 vention-docs.py --from-uv-lock uv.lock
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import http.client
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import urllib.request
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import IO, NoReturn
|
|
33
|
+
from urllib.parse import urljoin, urlsplit
|
|
34
|
+
|
|
35
|
+
INDEX_URL = "https://docs.vention.com/llms.txt"
|
|
36
|
+
PACKAGE = "machine-logic-sdk"
|
|
37
|
+
TIMEOUT = 20
|
|
38
|
+
ALLOWED_SCHEMES = frozenset({"http", "https", "file"})
|
|
39
|
+
|
|
40
|
+
ENTRY_PATTERN = re.compile(r"^- \[([^\]]+)\]\(([^)\s]+\.md)\)", re.MULTILINE)
|
|
41
|
+
LOCK_PATTERN = re.compile(r'\[\[package\]\]\s*\nname = "machine-logic-sdk"\s*\nversion = "([^"]+)"')
|
|
42
|
+
REQUIREMENT_PATTERN = re.compile(
|
|
43
|
+
r"^\s*machine[-_]logic[-_]sdk\s*(?:\[[^\]]*\])?\s*(?P<operator>[=<>!~]+)\s*(?P<version>[^\s;#,]+)",
|
|
44
|
+
re.IGNORECASE | re.MULTILINE,
|
|
45
|
+
)
|
|
46
|
+
EXACT_VERSION_PATTERN = re.compile(r"^\d+(\.\d+)+[\w.+-]*$")
|
|
47
|
+
CATCH_ALL_PATTERN = re.compile(rf"^(?:{re.escape(PACKAGE)}\s+)?v(?P<bound>\d+(?:\.\d+)*)\s+and\s+lower\b", re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Page:
|
|
52
|
+
title: str
|
|
53
|
+
url: str
|
|
54
|
+
series: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def unavailable(reason: str) -> NoReturn:
|
|
58
|
+
print(f"unavailable: {reason}", file=sys.stderr)
|
|
59
|
+
raise SystemExit(2)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def open_url(url: str) -> IO[bytes]:
|
|
63
|
+
scheme = urlsplit(url).scheme
|
|
64
|
+
if scheme not in ALLOWED_SCHEMES:
|
|
65
|
+
unavailable(f"unsupported url scheme {scheme}")
|
|
66
|
+
response: IO[bytes] = urllib.request.urlopen(url, timeout=TIMEOUT) # noqa: S310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
|
67
|
+
|
|
68
|
+
return response
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fetch(url: str) -> str:
|
|
72
|
+
try:
|
|
73
|
+
with open_url(url) as response:
|
|
74
|
+
return response.read().decode("utf-8", errors="replace")
|
|
75
|
+
except (OSError, http.client.HTTPException, ValueError) as error:
|
|
76
|
+
unavailable(f"cannot reach {url}: {error}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def version_from_requirements(path: Path) -> str:
|
|
80
|
+
if not path.is_file():
|
|
81
|
+
unavailable(f"no requirements file at {path}")
|
|
82
|
+
found = REQUIREMENT_PATTERN.search(path.read_text(encoding="utf-8-sig"))
|
|
83
|
+
if not found:
|
|
84
|
+
unavailable(f"{path} carries no machine-logic-sdk requirement")
|
|
85
|
+
operator, version = found.group("operator"), found.group("version")
|
|
86
|
+
if operator != "==":
|
|
87
|
+
unavailable(f"{path} allows machine-logic-sdk{operator}{version}, which is a range, not a version")
|
|
88
|
+
return version
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def version_from_uv_lock(path: Path) -> str:
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
unavailable(f"no lock file at {path}")
|
|
94
|
+
found = LOCK_PATTERN.search(path.read_text(encoding="utf-8-sig"))
|
|
95
|
+
if not found:
|
|
96
|
+
unavailable(f"{path} does not resolve machine-logic-sdk")
|
|
97
|
+
return found.group(1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def index_entries(index_url: str) -> list[tuple[str, str]]:
|
|
101
|
+
"""(title, absolute url) for every page the index lists, descriptions left unread."""
|
|
102
|
+
return [(title, urljoin(index_url, target)) for title, target in ENTRY_PATTERN.findall(fetch(index_url))]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def release_order(version: str) -> tuple[int, ...]:
|
|
106
|
+
"""Version components as numbers, so 1.13.3 sorts above 1.13.2 the way 'v1.13.3' does not."""
|
|
107
|
+
return tuple(int(part) for part in re.findall(r"\d+", version))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def page_for_version(entries: list[tuple[str, str]], version: str) -> Page:
|
|
111
|
+
"""The page titled for this version's minor series, or exit 2 saying none is published.
|
|
112
|
+
|
|
113
|
+
Matched on vMAJOR.MINOR, the grain the docs use (v3.1.x, v3.0.x) and the grain a pin gives. The
|
|
114
|
+
title has to start with it, so "Migration guide machine-logic-sdk v2.2.0 to v3.0.0" never
|
|
115
|
+
answers for 3.0.1. One title is a bound rather than a series -- "v1.13.2 and lower" -- and
|
|
116
|
+
answers for every version at or below it instead.
|
|
117
|
+
"""
|
|
118
|
+
wanted = "v" + ".".join(version.split(".")[:2])
|
|
119
|
+
titled = re.compile(rf"(?:{re.escape(PACKAGE)}\s+)?{re.escape(wanted)}(?![0-9])", re.IGNORECASE)
|
|
120
|
+
for title, url in entries:
|
|
121
|
+
stripped = title.strip()
|
|
122
|
+
catch_all = CATCH_ALL_PATTERN.match(stripped)
|
|
123
|
+
if catch_all:
|
|
124
|
+
bound = catch_all.group("bound")
|
|
125
|
+
if release_order(version) <= release_order(bound):
|
|
126
|
+
return Page(stripped, url, "v" + ".".join(bound.split(".")[:2]))
|
|
127
|
+
elif titled.match(stripped):
|
|
128
|
+
return Page(stripped, url, wanted)
|
|
129
|
+
published = sorted({found.group(0) for title, _ in entries if (found := re.search(r"v\d+\.\d+", title))})
|
|
130
|
+
unavailable(
|
|
131
|
+
f"the docs publish no {PACKAGE} page for {wanted} (published: {', '.join(published) or 'none'}); "
|
|
132
|
+
f"refusing to answer from another version"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main() -> int:
|
|
137
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
138
|
+
source = parser.add_mutually_exclusive_group(required=True)
|
|
139
|
+
source.add_argument("--version", help="the exact SDK version to document, e.g. 3.1.0")
|
|
140
|
+
source.add_argument("--from-requirements", metavar="PATH", help="read an exact machine-logic-sdk== pin from a requirements file")
|
|
141
|
+
source.add_argument("--from-uv-lock", metavar="PATH", help="read the resolved machine-logic-sdk version from a uv lock file")
|
|
142
|
+
parser.add_argument("--index-url", default=INDEX_URL, help=f"documentation index to read (default: {INDEX_URL})")
|
|
143
|
+
output = parser.add_mutually_exclusive_group()
|
|
144
|
+
output.add_argument("--print-url", action="store_true", help="print only the page URL")
|
|
145
|
+
output.add_argument("--print-page", action="store_true", help="fetch the page and print it verbatim")
|
|
146
|
+
arguments = parser.parse_args()
|
|
147
|
+
|
|
148
|
+
if arguments.from_requirements:
|
|
149
|
+
version = version_from_requirements(Path(arguments.from_requirements))
|
|
150
|
+
elif arguments.from_uv_lock:
|
|
151
|
+
version = version_from_uv_lock(Path(arguments.from_uv_lock))
|
|
152
|
+
else:
|
|
153
|
+
version = arguments.version
|
|
154
|
+
if not EXACT_VERSION_PATTERN.match(version):
|
|
155
|
+
unavailable(f"{version} is not an exact version")
|
|
156
|
+
|
|
157
|
+
page = page_for_version(index_entries(arguments.index_url), version)
|
|
158
|
+
|
|
159
|
+
if arguments.print_url:
|
|
160
|
+
print(page.url)
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
body = fetch(page.url)
|
|
164
|
+
if not re.search(rf"{re.escape(page.series)}(?![0-9])", body, re.IGNORECASE):
|
|
165
|
+
unavailable(f"{page.url} does not document {page.series}")
|
|
166
|
+
|
|
167
|
+
if arguments.print_page:
|
|
168
|
+
print(body, end="")
|
|
169
|
+
else:
|
|
170
|
+
print(f"{PACKAGE} {version} is documented by: {page.title}")
|
|
171
|
+
print(page.url)
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vention-machine-logic-hmi
|
|
3
|
+
description: Build or edit the custom operator HMI of a MachineLogic application, the React touch screen in customui/ that runs in MachineBuilder and on the MachineMotion pendant, wired to the application's Python process through vention-communication. Use when the user asks for an operator screen, a custom HMI or UI, a touch panel, start and stop buttons, live machine state on screen, or recipes an operator picks, for a Vention machine or inside a directory holding .machine-code-app-directory-info.json. Uses @vention/machine-ui, @vention/machine-apps-components and @vention/machine-logic-ui-sdk. Do not use for the no-code HMI Builder, or for the Python motion logic alone (that is vention-machine-logic).
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
version: "0.1.0"
|
|
7
|
+
author: Vention
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Vention MachineLogic HMIs
|
|
11
|
+
|
|
12
|
+
A custom HMI is a React app in `customui/` that an operator uses on a touch screen. It talks to one Python process, `server.py` served by uvicorn, which owns the machine and exposes an RPC surface through `vention-communication`. The HMI reaches that process only through the client generated from the proto the server emits. For the machine calls themselves (devices, moves, safety states), follow the `vention-machine-logic` skill.
|
|
13
|
+
|
|
14
|
+
## Before you start
|
|
15
|
+
|
|
16
|
+
1. Work only in a directory the Vention CLI has linked: `.machine-code-app-directory-info.json` is in its root. If it is missing, ask the user to run `vention pull` or `vention link` there. Never create the marker by hand.
|
|
17
|
+
2. Read `.vention-design-configuration.json` for device names, character for character, as the `vention-machine-logic` skill says.
|
|
18
|
+
3. If `customui/` is missing, copy `package.json`, `buf.gen.yaml`, `tsconfig.json` and `index.html` from `examples/indexing-station-hmi/customui/`, rename the package, then write `src/`.
|
|
19
|
+
|
|
20
|
+
## The layout the engine expects
|
|
21
|
+
|
|
22
|
+
| Path | What it is |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `customui/` | The HMI source and its `package.json`. The engine runs `npm install`, then `npm run build`, in it. |
|
|
25
|
+
| `ui/` | Build output only. The build script writes `../ui/index.html` and `../ui/assets/`. Never edit it or read versions from it. |
|
|
26
|
+
| `project.json` | One process under `processes.onPlay`: `{ name, command, port }`. The command sets its own port. |
|
|
27
|
+
| `requirements.txt` | Exact pins for `machine-logic-sdk`, `vention-communication`, `vention-state-machine`, `vention-storage` and `uvicorn`. |
|
|
28
|
+
| `proto/app.proto` | Written by `app.finalize()` when `server.py` is imported. Commit it; never edit it. |
|
|
29
|
+
| `customui/src/gen/` | The client `buf generate ../proto` writes before every build. Gitignored; never commit or edit it. |
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{ "processes": { "onPlay": [{ "name": "indexing-station", "command": "python3 -m uvicorn server:app --host 0.0.0.0 --port 8000", "port": 8000 }] } }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## The Python surface
|
|
36
|
+
|
|
37
|
+
One `VentionApp` carries everything: the state machine bundle, the storage bundle, `@action` commands and `@stream` feeds. Register every plugin, then call `finalize()` last.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
bootstrap(database_url=f"sqlite:///{DATA_DIR / 'recipes.db'}")
|
|
41
|
+
recipe_accessor = ModelAccessor(Recipe, "recipe")
|
|
42
|
+
station = IndexingStation() # a StateMachine subclass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@asynccontextmanager
|
|
46
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
47
|
+
try:
|
|
48
|
+
station.machine = await asyncio.to_thread(Machine)
|
|
49
|
+
except Exception as error:
|
|
50
|
+
station.fault_message = f"cannot reach the controller: {error}"
|
|
51
|
+
await publish_status()
|
|
52
|
+
yield
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
app = VentionApp(name="IndexingStation", emit_proto=True, proto_path="proto/app.proto", lifespan=lifespan)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@action("Start")
|
|
59
|
+
async def start(request: StartRequest) -> CommandResponse:
|
|
60
|
+
... # refuse unless connected and ready, load the recipe, then station.trigger(Triggers.run.name)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
app.register_rpc_plugin(build_state_machine_rpc_bundle(station, triggers=[Triggers.stop.name, BaseTriggers.RESET.value]))
|
|
64
|
+
app.register_rpc_plugin(build_storage_rpc_bundle(accessors=[recipe_accessor], max_records_per_model=None))
|
|
65
|
+
app.finalize()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- Create `Machine()` in the FastAPI `lifespan`, through `asyncio.to_thread`, never at import. Importing `server.py` must work with no machine attached: that is how the proto is emitted. When it fails, show the failure as a fault the operator can read.
|
|
69
|
+
- `@action("Start")` names the RPC `Start`. Without the name the RPC takes the function's name as written.
|
|
70
|
+
- The state machine bundle adds `GetState`, `GetHistory` and one `Trigger_<Name>` per trigger you list. List only the triggers the operator may fire.
|
|
71
|
+
- The storage bundle adds `Recipe_ListRecords`, `Recipe_CreateRecord`, `Recipe_UpdateRecord` and `Recipe_DeleteRecord` among others, named after the accessor. `max_records_per_model=None` lifts its default cap of 5 records.
|
|
72
|
+
- The storage bundle also adds `Database_*` RPCs, including a whole-database restore. Never expose them to operators.
|
|
73
|
+
- Give every storage model field a zero-value default (`dwell_ms: int = 0`). The client leaves zero values out of the JSON it sends, and a field with no default then fails as NULL.
|
|
74
|
+
- Every storage write takes a non-empty `actor`, the name recorded in the audit log.
|
|
75
|
+
|
|
76
|
+
### Running the cycle
|
|
77
|
+
|
|
78
|
+
Blocking SDK calls, cancellation and the shutdown path follow the `vention-machine-logic` skill's "Doing several things at once". What the server adds:
|
|
79
|
+
|
|
80
|
+
- Start the cycle from an `@on_enter_state` handler with `self.spawn(...)`.
|
|
81
|
+
- Never publish a stream or fire a trigger from inside a worker thread. Return to the loop first.
|
|
82
|
+
- Make one thread call per move and check a stop flag between them, so Stop finishes the move in progress. Release what the cycle holds (clamp open, outputs off) before you fire `BaseTriggers.TO_FAULT` or re-raise.
|
|
83
|
+
|
|
84
|
+
## Live data is a stream
|
|
85
|
+
|
|
86
|
+
Anything the HMI shows live comes from a Connect server stream. Never poll `GetState` or a list RPC on a timer.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
@stream(name="Status", payload=StationStatus, replay=True, queue_maxsize=1, policy="latest")
|
|
90
|
+
async def publish_status() -> StationStatus:
|
|
91
|
+
return station.snapshot()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@stream(name="Events", payload=Event, replay=False, queue_maxsize=100, policy="fifo")
|
|
95
|
+
async def publish_event(level: str, message: str, detail: str) -> Event:
|
|
96
|
+
return Event(timestamp=datetime.now(timezone.utc).isoformat(), level=level, message=message, detail=detail)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Awaiting the function publishes its result. Publish status from an `@on_state_change` method on the state machine (`self.spawn(publish_status())`) and after every count or fault change. `replay=True` hands a new subscriber the last status at once; the event log uses `replay=False` so a reconnect does not repeat old events. Also republish status every `STATUS_HEARTBEAT_S` from a task the `lifespan` starts, as the example does: a connection can hang without closing, and the HMI only notices when the heartbeat stops.
|
|
100
|
+
|
|
101
|
+
## The generated client
|
|
102
|
+
|
|
103
|
+
`npm run build` regenerates `src/gen/` from `proto/app.proto` first (the `prebuild` script), so the engine always builds against the committed proto. Re-emit the proto after every change to the Python surface, then run `npm run build` to refresh the client. RPC names become camelCase methods (`Trigger_Stop` is `client.trigger_Stop`), and snake_case fields become camelCase (`recipe_id` is `recipeId`).
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { createClient } from "@connectrpc/connect"
|
|
107
|
+
import { createConnectTransport } from "@connectrpc/connect-web"
|
|
108
|
+
import { getMachineCodeProcessHttpUrl } from "@vention/machine-logic-ui-sdk"
|
|
109
|
+
import { IndexingStationService } from "./gen/app_pb"
|
|
110
|
+
|
|
111
|
+
// Must match the process name in project.json.
|
|
112
|
+
const PROCESS_NAME = "indexing-station"
|
|
113
|
+
|
|
114
|
+
export const client = createClient(
|
|
115
|
+
IndexingStationService,
|
|
116
|
+
createConnectTransport({ baseUrl: `${getMachineCodeProcessHttpUrl(PROCESS_NAME)}/rpc`, useBinaryFormat: false })
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
export const openStatus = (signal: AbortSignal) => client.status({}, { signal })
|
|
120
|
+
export const openEvents = (signal: AbortSignal) => client.events({}, { signal })
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Copy `examples/indexing-station-hmi/customui/src/use-stream.ts` for the stream hook. Pass it a stable opener such as `openStatus`: it aborts on unmount, reopens a dropped stream with backoff, and returns `{ value, connected }` so the page can show Disconnected. Pass `STATUS_STALL_MS`, three heartbeats, as the second argument for the status stream only; a stream with no heartbeat, such as events, would look stalled whenever nothing happens. No `fetch`, `XMLHttpRequest`, `EventSource` or `WebSocket` anywhere in `customui/src`.
|
|
124
|
+
|
|
125
|
+
## Which component for which job
|
|
126
|
+
|
|
127
|
+
| Job | Use |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| Screen shell, from `@vention/machine-apps-components` | `StatusTopBar`, `NavigationBar`, `LogsPanel`, `SettingsPage`, `I18nProvider` when the screen is translated |
|
|
130
|
+
| Actions, from `@vention/machine-ui` | `VentionButton`, `VentionIconButton` |
|
|
131
|
+
| Inputs | `VentionSelect`, `VentionTextInput`, `VentionStepper`, `VentionSwitch`, `VentionCheckbox`, `VentionSlider` |
|
|
132
|
+
| State and feedback | `VentionStatusIndicator`, `VentionAlert`, `VentionBanner`, `VentionProgressBar` |
|
|
133
|
+
| Overlays and navigation | `VentionModal`, `VentionDrawer`, `VentionTabs`, `VentionSteps` |
|
|
134
|
+
| Touch and the process URL, from `@vention/machine-logic-ui-sdk` | `VirtualKeyboard`, `isTouchScreenDevice`, `getMachineCodeProcessHttpUrl` |
|
|
135
|
+
|
|
136
|
+
Layout comes from MUI `Box`, `Stack` and `Typography` only, with `ThemeProvider` at the root. Every other MUI control has a Vention equivalent above; use it. Route with `HashRouter`: the bundle is served under a prefix you cannot know, at `.../index.html`.
|
|
137
|
+
|
|
138
|
+
Read each library's README at the version `customui/package.json` pins before using a prop you have not seen in the example. The script ships in the `vention-machine-logic` skill, which must be installed beside this one:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python3 ../vention-machine-logic/scripts/library-readme.py --from-app <application directory>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The machine-apps-components README is behind its code: `NavigationBar` takes `NavigationBar.Item` children, not `navigationItems`, and a `LogsPanel` fetcher returns `hasMorePages`, not `hasMore`. When a README and `npx tsc --noEmit` disagree, the compiler is right.
|
|
145
|
+
|
|
146
|
+
## Design rules
|
|
147
|
+
|
|
148
|
+
- Wrap the app in `machineUiHmiTheme`.
|
|
149
|
+
- Set `size="x-large"` or `size="xx-large"` on every control that takes a size. The default `"small"` is 24 px, below the 44 px touch minimum.
|
|
150
|
+
- Text uses the `heading*` and `paragraph*` typography variants: `heading24SemiBold` for what the operator must see from a distance, such as the cycle count, `heading18SemiBold` for labels, `paragraph18Medium` for detail. The theme marks `hmiText*` deprecated.
|
|
151
|
+
- Leave at least 16 px between adjacent touch targets and 24 px between buttons with opposite effects, such as Save and Delete. `theme.spacing` is a lookup table, not a multiplier: `gap: 2` is 8 px, `gap: 4` is 16 px, `gap: 5` is 24 px.
|
|
152
|
+
- Give each state one color from `COLORS` and always a text label next to it: green for running, amber for stopping or a warning, red for a fault, slate for ready, idle or disconnected.
|
|
153
|
+
- Nothing an operator needs lives only in a tooltip. No drag, swipe or multi-touch gestures: every action is a tap.
|
|
154
|
+
- Render `VirtualKeyboard` when `isTouchScreenDevice()` is true, so text inputs work on the pendant.
|
|
155
|
+
- Confirm anything that starts motion or deletes data in a `VentionModal` with `isTouchDevice`, and say in it what will move.
|
|
156
|
+
- `VentionButton` variants are `filled-brand`, `filled`, `outline`, `text-only`, `shaded` and `destructive`. MUI's `contained` and `outlined` do not exist on it, and neither does a `color` prop.
|
|
157
|
+
- Start is green and Stop is red, both set through the `StatusTopBar` button's `backgroundColor`, `backgroundColorHover` and `textColor`.
|
|
158
|
+
- A `LogsPanel` row cuts `message` and `description` to one line and shows the rest only on hover. Keep `message` to a few words saying what happened, put the cause and what to do next in `description`, and open the whole entry in a `VentionModal` from `onLogClick`.
|
|
159
|
+
|
|
160
|
+
## Validate before you push
|
|
161
|
+
|
|
162
|
+
1. In the application directory, with the `requirements.txt` packages installed locally: `python3 -c "import server"` to re-emit `proto/app.proto`, and commit it if `git status` shows a change.
|
|
163
|
+
2. In `customui/`: `npm install`, `npm run build`, then `npx tsc --noEmit`, and check that `ui/index.html` exists and loads its script from `./assets/`, a relative path.
|
|
164
|
+
3. `vention push`, then open the HMI from the MachineLogic tab in MachineBuilder and run it on the digital twin before any hardware run.
|
|
165
|
+
4. Read `vention logs` when a command fails; the server's errors land there.
|
|
166
|
+
|
|
167
|
+
## Safety
|
|
168
|
+
|
|
169
|
+
The HMI drives physical machinery that can injure someone.
|
|
170
|
+
|
|
171
|
+
- Start never fires on page load, on reconnect, or from a stream message. Only an operator's tap, confirmed in a modal, starts motion.
|
|
172
|
+
- Start and Stop live in the `StatusTopBar`, so they are on every page. Do not repeat them on a page. Raise the bar above `theme.zIndex.modal`, as the example's `app.tsx` does, or an open `VentionModal` covers Stop.
|
|
173
|
+
- Stop stays enabled unless the station is known to be ready. That includes while the stream is disconnected: a dropped stream may hide a moving machine. Every other command is disabled while disconnected.
|
|
174
|
+
- Send Start with a `timeoutMs` and an abort signal, and abort it when the stream drops. A Start the operator can no longer see must not land later; say it was not confirmed and to check the station.
|
|
175
|
+
- The HMI never offers a way around an emergency stop, safety interlock, light curtain or guard, not even a hidden one for testing.
|
|
176
|
+
- The confirmation for anything that starts motion says what will move. The button itself can just say Start.
|
|
177
|
+
|
|
178
|
+
## Complete example
|
|
179
|
+
|
|
180
|
+
`examples/indexing-station-hmi/` indexes a part on a conveyor, clamps it, proves the clamp closed, dwells and releases, for the cycle count of the recipe the operator picked.
|
|
181
|
+
|
|
182
|
+
- `server.py`: the state machine, the streams, the Start action, both bundles, and `Machine()` in the `lifespan`.
|
|
183
|
+
- `models.py`: the `Recipe` storage model, every field with a zero default.
|
|
184
|
+
- `project.json`, `requirements.txt`: the one process and the exact pins.
|
|
185
|
+
- `proto/app.proto`: what `finalize()` emits.
|
|
186
|
+
- `customui/package.json`, `customui/buf.gen.yaml`, `customui/tsconfig.json`, `customui/index.html`: the scaffold to copy.
|
|
187
|
+
- `customui/src/client.ts`, `customui/src/use-stream.ts`: the generated client and the stream hook.
|
|
188
|
+
- `customui/src/station.ts`: state labels and colors, and when Start and Stop are enabled.
|
|
189
|
+
- `customui/src/app.tsx`, `customui/src/main.tsx`: the shell with Start, Stop and the Start confirmation, the theme, the router and the keyboard.
|
|
190
|
+
- `customui/src/pages/`: the Run, Recipes and Logs pages.
|
|
191
|
+
|
|
192
|
+
## Common mistakes
|
|
193
|
+
|
|
194
|
+
- Polling for live state instead of a stream.
|
|
195
|
+
- A raw `fetch` to `/rpc`, or any request that skips the generated client.
|
|
196
|
+
- Editing or committing `src/gen/`, or forgetting to re-emit `proto/app.proto` after a Python change.
|
|
197
|
+
- Dropping `chmod -R g+w` from the build scripts. The engine deletes the application as another user, and it cannot remove files the build left group-read-only.
|
|
198
|
+
- A stream named after its payload model: `rpc Status` returning `Status` fails in buf. Name the payload `StationStatus`.
|
|
199
|
+
- A storage field with no zero default, or a storage write with no `actor`.
|
|
200
|
+
- A process name in `client.ts` that differs from `project.json`.
|
|
201
|
+
- `Machine()` at import, or a retry loop around it in the same process.
|
|
202
|
+
- A blocking SDK call on the event loop, or a publish or trigger from inside `asyncio.to_thread`.
|
|
203
|
+
- Trusting task cancellation to stop motion already running in a thread.
|
|
204
|
+
- MUI `Button` where `VentionButton` exists, or MUI variants such as `contained` on a Vention button.
|
|
205
|
+
- `size` left at the `"small"` default on a touch screen.
|
|
206
|
+
- `BrowserRouter`, whose routes miss under `.../index.html`.
|
|
207
|
+
- Reading versions from `ui/package.json`; the manifest is `customui/package.json`.
|
|
208
|
+
- Disabling Stop whenever the stream drops.
|
|
209
|
+
- `--legacy-peer-deps` to get past a peer dependency conflict. Pin versions whose peers agree, as the example does.
|
|
210
|
+
- A Start still in flight after the connection drops.
|