@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.
Files changed (35) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +15 -0
  3. package/README.md +61 -0
  4. package/mcp.json +9 -0
  5. package/package.json +19 -0
  6. package/plugin.json +12 -0
  7. package/skills/vention-design/SKILL.md +522 -0
  8. package/skills/vention-machine-logic/SKILL.md +256 -0
  9. package/skills/vention-machine-logic/examples/conveyor-with-sensor/main.py +58 -0
  10. package/skills/vention-machine-logic/examples/homing-and-indexing/main.py +51 -0
  11. package/skills/vention-machine-logic/examples/parallel-conveyor-with-recipe/main.py +118 -0
  12. package/skills/vention-machine-logic/examples/parallel-conveyor-with-recipe/requirements.txt +1 -0
  13. package/skills/vention-machine-logic/examples/pick-and-place-state-machine/main.py +159 -0
  14. package/skills/vention-machine-logic/examples/pick-and-place-state-machine/requirements.txt +2 -0
  15. package/skills/vention-machine-logic/scripts/library-readme.py +233 -0
  16. package/skills/vention-machine-logic/scripts/vention-docs.py +176 -0
  17. package/skills/vention-machine-logic-hmi/SKILL.md +210 -0
  18. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/buf.gen.yaml +5 -0
  19. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/index.html +11 -0
  20. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/package.json +34 -0
  21. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/app.tsx +152 -0
  22. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/client.ts +17 -0
  23. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/main.tsx +16 -0
  24. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/pages/logs-page.tsx +52 -0
  25. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/pages/recipes-page.tsx +139 -0
  26. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/pages/run-page.tsx +65 -0
  27. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/station.ts +34 -0
  28. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/use-stream.ts +54 -0
  29. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/tsconfig.json +24 -0
  30. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/models.py +13 -0
  31. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/project.json +6 -0
  32. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/proto/app.proto +192 -0
  33. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/requirements.txt +5 -0
  34. package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/server.py +242 -0
  35. 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.
@@ -0,0 +1,5 @@
1
+ version: v2
2
+ plugins:
3
+ - local: protoc-gen-es
4
+ opt: target=ts
5
+ out: src/gen
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Indexing station</title>
6
+ </head>
7
+ <body>
8
+ <div id="root"></div>
9
+ <script type="module" src="/src/main.tsx"></script>
10
+ </body>
11
+ </html>