@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,34 @@
1
+ {
2
+ "name": "indexing-station-hmi",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "prebuild": "rm -rf src/gen && buf generate ../proto && chmod -R g+w src/gen",
7
+ "build": "esbuild src/main.tsx --bundle --outdir=../ui/assets --format=esm --target=esnext --loader:.tsx=tsx --loader:.ts=ts --loader:.css=css --splitting --entry-names=main --chunk-names=[name]-[hash] && sed 's|/src/main.tsx|./assets/main.js|g' index.html | sed 's|</head>|<link rel=\"stylesheet\" href=\"./assets/main.css\"></head>|g' > ../ui/index.html && chmod -R g+w ../ui",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "dependencies": {
11
+ "@bufbuild/protobuf": "2.12.0",
12
+ "@connectrpc/connect": "2.1.1",
13
+ "@connectrpc/connect-web": "2.1.1",
14
+ "@emotion/react": "11.14.0",
15
+ "@emotion/styled": "11.14.1",
16
+ "@mui/material": "5.16.7",
17
+ "@tabler/icons-react": "3.44.0",
18
+ "@vention/machine-apps-components": "1.26.11",
19
+ "@vention/machine-logic-ui-sdk": "0.43.17",
20
+ "@vention/machine-ui": "5.30.11",
21
+ "react": "17.0.2",
22
+ "react-dom": "17.0.2",
23
+ "react-router-dom": "6.30.3",
24
+ "tss-react": "4.9.21"
25
+ },
26
+ "devDependencies": {
27
+ "@bufbuild/buf": "1.70.0",
28
+ "@bufbuild/protoc-gen-es": "2.12.0",
29
+ "@types/react": "17.0.92",
30
+ "@types/react-dom": "17.0.26",
31
+ "esbuild": "0.28.1",
32
+ "typescript": "5.9.3"
33
+ }
34
+ }
@@ -0,0 +1,152 @@
1
+ import { Box } from "@mui/material"
2
+ import { IconBook, IconHistory, IconPlayerPlay, IconPlayerPlayFilled, IconPlayerStopFilled } from "@tabler/icons-react"
3
+ import { NavigationBar, StatusTopBar } from "@vention/machine-apps-components"
4
+ import type { LogEntry, LogType } from "@vention/machine-apps-components"
5
+ import { COLORS, VentionAlert, VentionModal } from "@vention/machine-ui"
6
+ import { useEffect, useRef, useState } from "react"
7
+ import { Navigate, Route, Routes } from "react-router-dom"
8
+ import { client, openEvents, openStatus, STATUS_STALL_MS } from "./client"
9
+ import type { Event, Recipe } from "./gen/app_pb"
10
+ import { LogsPage } from "./pages/logs-page"
11
+ import { RecipesPage } from "./pages/recipes-page"
12
+ import { RunPage } from "./pages/run-page"
13
+ import { canStart, canStop, describeState, errorText } from "./station"
14
+ import { useStream } from "./use-stream"
15
+
16
+ // Events are kept in memory from when the HMI opened, so the cap bounds a long shift.
17
+ const MAX_LOG_ENTRIES = 500
18
+ const NAV_ICON_SIZE = 42
19
+ const TOP_BAR_ICON_SIZE = 32
20
+ // A Start that has not been answered by then is abandoned rather than left to land later.
21
+ const START_TIMEOUT_MS = 5000
22
+
23
+ function toLogLevel(level: string): LogType {
24
+ if (level === "error") return "error"
25
+ if (level === "warning" || level === "warn") return "warning"
26
+ return "info"
27
+ }
28
+
29
+ function useEventLog(): LogEntry[] {
30
+ const { value: latestEvent } = useStream<Event>(openEvents)
31
+ const [entries, setEntries] = useState<LogEntry[]>([])
32
+ const nextId = useRef(0)
33
+
34
+ useEffect(() => {
35
+ if (latestEvent === undefined) return
36
+ nextId.current += 1
37
+ const entry: LogEntry = {
38
+ id: String(nextId.current),
39
+ date: latestEvent.timestamp,
40
+ level: toLogLevel(latestEvent.level),
41
+ code: latestEvent.level.toUpperCase(),
42
+ message: latestEvent.message,
43
+ description: latestEvent.detail,
44
+ }
45
+ setEntries(previous => [entry, ...previous].slice(0, MAX_LOG_ENTRIES))
46
+ }, [latestEvent])
47
+
48
+ return entries
49
+ }
50
+
51
+ export function App() {
52
+ const { value: status, connected } = useStream(openStatus, STATUS_STALL_MS)
53
+ const logEntries = useEventLog()
54
+ const [commandError, setCommandError] = useState("")
55
+ const state = describeState(status, connected)
56
+
57
+ const [recipe, setRecipe] = useState<Recipe | undefined>(undefined)
58
+ const [confirmingStart, setConfirmingStart] = useState(false)
59
+ const startRequest = useRef<AbortController | undefined>(undefined)
60
+ const startable = canStart(status, connected) && recipe !== undefined
61
+
62
+ useEffect(() => {
63
+ if (!connected) startRequest.current?.abort()
64
+ }, [connected])
65
+
66
+ const start = async () => {
67
+ setConfirmingStart(false)
68
+ if (recipe === undefined) return
69
+ const controller = new AbortController()
70
+ startRequest.current = controller
71
+ try {
72
+ const response = await client.start({ recipeId: recipe.id }, { signal: controller.signal, timeoutMs: START_TIMEOUT_MS })
73
+ if (!response.accepted) setCommandError(`Start refused: ${response.message}`)
74
+ } catch (error) {
75
+ setCommandError(`Start not confirmed: ${errorText(error)}. Check the station state before starting again.`)
76
+ }
77
+ }
78
+
79
+ const stop = () => {
80
+ client.trigger_Stop({}).catch((error: unknown) => setCommandError(`Stop failed: ${errorText(error)}`))
81
+ }
82
+
83
+ return (
84
+ <Box sx={{ height: "100vh", width: "100vw", display: "flex", flexDirection: "column", overflow: "hidden" }}>
85
+ {/* Above every modal's backdrop, so Stop stays one tap away while a dialog is open. */}
86
+ <Box sx={{ position: "relative", zIndex: theme => theme.zIndex.modal + 1 }}>
87
+ <StatusTopBar
88
+ variant="dark"
89
+ status={{ label: state.label, color: state.color }}
90
+ buttonConfigs={[
91
+ {
92
+ id: "start",
93
+ label: "Start",
94
+ onClick: () => setConfirmingStart(true),
95
+ disabled: !startable,
96
+ backgroundColor: COLORS.green[600],
97
+ backgroundColorHover: COLORS.green[700],
98
+ textColor: "white",
99
+ icon: <IconPlayerPlayFilled size={TOP_BAR_ICON_SIZE} color="white" />,
100
+ },
101
+ {
102
+ id: "stop",
103
+ label: "Stop",
104
+ onClick: stop,
105
+ disabled: !canStop(status, connected),
106
+ backgroundColor: COLORS.red[600],
107
+ backgroundColorHover: COLORS.red[700],
108
+ textColor: "white",
109
+ icon: <IconPlayerStopFilled size={TOP_BAR_ICON_SIZE} color="white" />,
110
+ },
111
+ ]}
112
+ />
113
+ </Box>
114
+ {commandError !== "" && (
115
+ <VentionAlert
116
+ severity="error"
117
+ size="xx-large"
118
+ title={commandError}
119
+ isFullWidth
120
+ isCloseable
121
+ onCloseButtonClick={() => setCommandError("")}
122
+ />
123
+ )}
124
+ <Box sx={{ flex: 1, minHeight: 0, overflow: "auto", padding: 6 }}>
125
+ <Routes>
126
+ <Route
127
+ path="/"
128
+ element={<RunPage status={status} connected={connected} recipe={recipe} onRecipeChange={setRecipe} onError={setCommandError} />}
129
+ />
130
+ <Route path="/recipes" element={<RecipesPage connected={connected} onError={setCommandError} />} />
131
+ <Route path="/logs" element={<LogsPage entries={logEntries} />} />
132
+ <Route path="*" element={<Navigate to="/" replace />} />
133
+ </Routes>
134
+ </Box>
135
+ <NavigationBar variant="flex">
136
+ <NavigationBar.Item id="run" label="Run" path="/" icon={<IconPlayerPlay size={NAV_ICON_SIZE} color="white" />} />
137
+ <NavigationBar.Item id="recipes" label="Recipes" path="/recipes" icon={<IconBook size={NAV_ICON_SIZE} color="white" />} />
138
+ <NavigationBar.Item id="logs" label="Logs" path="/logs" icon={<IconHistory size={NAV_ICON_SIZE} color="white" />} />
139
+ </NavigationBar>
140
+ <VentionModal
141
+ isOpen={confirmingStart && startable}
142
+ onClose={() => setConfirmingStart(false)}
143
+ isTouchDevice
144
+ type="warning"
145
+ titleText={`Start ${recipe?.name ?? ""}?`}
146
+ body="The conveyor will move."
147
+ primaryButton={{ text: "Start", onClick: () => void start() }}
148
+ secondaryButton={{ text: "Cancel", onClick: () => setConfirmingStart(false) }}
149
+ />
150
+ </Box>
151
+ )
152
+ }
@@ -0,0 +1,17 @@
1
+ import { createClient } from "@connectrpc/connect"
2
+ import { createConnectTransport } from "@connectrpc/connect-web"
3
+ import { getMachineCodeProcessHttpUrl } from "@vention/machine-logic-ui-sdk"
4
+ import { IndexingStationService } from "./gen/app_pb"
5
+
6
+ // Must match the process name in project.json.
7
+ const PROCESS_NAME = "indexing-station"
8
+
9
+ export const client = createClient(
10
+ IndexingStationService,
11
+ createConnectTransport({ baseUrl: `${getMachineCodeProcessHttpUrl(PROCESS_NAME)}/rpc`, useBinaryFormat: false })
12
+ )
13
+
14
+ export const openStatus = (signal: AbortSignal) => client.status({}, { signal })
15
+ // Three of the server's STATUS_HEARTBEAT_S repeats.
16
+ export const STATUS_STALL_MS = 6000
17
+ export const openEvents = (signal: AbortSignal) => client.events({}, { signal })
@@ -0,0 +1,16 @@
1
+ import { ThemeProvider } from "@mui/material"
2
+ import { isTouchScreenDevice, VirtualKeyboard } from "@vention/machine-logic-ui-sdk"
3
+ import { machineUiHmiTheme } from "@vention/machine-ui"
4
+ import ReactDOM from "react-dom"
5
+ import { HashRouter } from "react-router-dom"
6
+ import { App } from "./app"
7
+
8
+ ReactDOM.render(
9
+ <ThemeProvider theme={machineUiHmiTheme}>
10
+ <HashRouter>
11
+ <App />
12
+ {isTouchScreenDevice() && <VirtualKeyboard />}
13
+ </HashRouter>
14
+ </ThemeProvider>,
15
+ document.getElementById("root")
16
+ )
@@ -0,0 +1,52 @@
1
+ import { LogsPanel } from "@vention/machine-apps-components"
2
+ import type { FetchParams, FetchResult, LogEntry, LogType } from "@vention/machine-apps-components"
3
+ import { VentionModal } from "@vention/machine-ui"
4
+ import type { VentionModalProps } from "@vention/machine-ui"
5
+ import { useCallback, useState } from "react"
6
+
7
+ const PAGE_SIZE = 10
8
+ const MODAL_TYPE: Record<LogType, VentionModalProps["type"]> = { error: "negative", warning: "warning", info: "info" }
9
+
10
+ function matchesFilters(entry: LogEntry, { filters }: FetchParams): boolean {
11
+ const time = new Date(entry.date).getTime()
12
+ if (filters.logType && entry.level !== filters.logType) return false
13
+ if (filters.fromDate && time < new Date(`${filters.fromDate}T00:00:00`).getTime()) return false
14
+ if (filters.toDate && time > new Date(`${filters.toDate}T23:59:59.999`).getTime()) return false
15
+ return true
16
+ }
17
+
18
+ export function LogsPage({ entries }: { entries: LogEntry[] }) {
19
+ const [openEntry, setOpenEntry] = useState<LogEntry | undefined>(undefined)
20
+ const dataFetcher = useCallback(
21
+ async (params: FetchParams): Promise<FetchResult> => {
22
+ const matching = entries.filter(entry => matchesFilters(entry, params))
23
+ if (params.filters.sortOrder === "oldest") matching.reverse()
24
+ const totalPages = Math.max(1, Math.ceil(matching.length / params.pageSize))
25
+ const start = (params.page - 1) * params.pageSize
26
+ return {
27
+ logs: matching.slice(start, start + params.pageSize),
28
+ totalCount: matching.length,
29
+ totalPages,
30
+ currentPage: params.page,
31
+ hasMorePages: params.page < totalPages,
32
+ }
33
+ },
34
+ [entries]
35
+ )
36
+
37
+ // The table shows long text in full only on hover, so a tap opens the whole entry.
38
+ return (
39
+ <>
40
+ <LogsPanel dataFetcher={dataFetcher} pagination={{ mode: "pagination", pageSize: PAGE_SIZE }} onLogClick={setOpenEntry} />
41
+ <VentionModal
42
+ isOpen={openEntry !== undefined}
43
+ onClose={() => setOpenEntry(undefined)}
44
+ isTouchDevice
45
+ type={openEntry ? MODAL_TYPE[openEntry.level] : "info"}
46
+ titleText={openEntry?.message ?? ""}
47
+ body={openEntry?.description ?? ""}
48
+ primaryButton={{ text: "Close", onClick: () => setOpenEntry(undefined) }}
49
+ />
50
+ </>
51
+ )
52
+ }
@@ -0,0 +1,139 @@
1
+ import { Box, Typography } from "@mui/material"
2
+ import { VentionButton, VentionModal, VentionStepper, VentionTextInput } from "@vention/machine-ui"
3
+ import { useCallback, useEffect, useState } from "react"
4
+ import { client } from "../client"
5
+ import type { Recipe } from "../gen/app_pb"
6
+ import { ACTOR, errorText } from "../station"
7
+
8
+ type RecipeFields = Pick<Recipe, "name" | "indexDistanceMm" | "speedMmS" | "dwellMs" | "cycleCount">
9
+ type NumericField = Exclude<keyof RecipeFields, "name">
10
+
11
+ const NEW_RECIPE: RecipeFields = { name: "", indexDistanceMm: 100, speedMmS: 100, dwellMs: 500, cycleCount: 10 }
12
+
13
+ const NUMERIC_FIELDS: { field: NumericField; label: string; unit: string; step: number; min: number }[] = [
14
+ { field: "indexDistanceMm", label: "Index distance", unit: "mm", step: 5, min: 5 },
15
+ { field: "speedMmS", label: "Speed", unit: "mm/s", step: 10, min: 10 },
16
+ { field: "dwellMs", label: "Dwell", unit: "ms", step: 100, min: 0 },
17
+ { field: "cycleCount", label: "Cycles", unit: "", step: 1, min: 1 },
18
+ ]
19
+
20
+ interface RecipesPageProps {
21
+ connected: boolean
22
+ onError: (message: string) => void
23
+ }
24
+
25
+ export function RecipesPage({ connected, onError }: RecipesPageProps) {
26
+ const [recipes, setRecipes] = useState<Recipe[]>([])
27
+ const [editingId, setEditingId] = useState<number | undefined>(undefined)
28
+ const [draft, setDraft] = useState<RecipeFields>(NEW_RECIPE)
29
+ const [confirmingDelete, setConfirmingDelete] = useState(false)
30
+
31
+ const reload = useCallback(async () => {
32
+ try {
33
+ setRecipes((await client.recipe_ListRecords({})).records)
34
+ } catch (error) {
35
+ onError(`Could not load recipes: ${errorText(error)}`)
36
+ }
37
+ }, [onError])
38
+
39
+ useEffect(() => {
40
+ if (connected) void reload()
41
+ }, [connected, reload])
42
+
43
+ const edit = (recipe: Recipe | undefined) => {
44
+ setEditingId(recipe?.id)
45
+ setDraft(recipe ?? NEW_RECIPE)
46
+ }
47
+
48
+ const save = async () => {
49
+ try {
50
+ const response =
51
+ editingId === undefined
52
+ ? await client.recipe_CreateRecord({ record: draft, actor: ACTOR })
53
+ : await client.recipe_UpdateRecord({ recordId: editingId, record: { ...draft, id: editingId }, actor: ACTOR })
54
+ setEditingId(response.record?.id)
55
+ await reload()
56
+ } catch (error) {
57
+ onError(`Could not save ${draft.name}: ${errorText(error)}`)
58
+ }
59
+ }
60
+
61
+ const remove = async () => {
62
+ setConfirmingDelete(false)
63
+ if (editingId === undefined) return
64
+ try {
65
+ await client.recipe_DeleteRecord({ recordId: editingId, actor: ACTOR })
66
+ edit(undefined)
67
+ await reload()
68
+ } catch (error) {
69
+ onError(`Could not delete ${draft.name}: ${errorText(error)}`)
70
+ }
71
+ }
72
+
73
+ return (
74
+ <Box sx={{ display: "flex", gap: 8 }}>
75
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 4, width: 320 }}>
76
+ <VentionButton size="x-large" variant={editingId === undefined ? "filled" : "outline"} onClick={() => edit(undefined)}>
77
+ New recipe
78
+ </VentionButton>
79
+ {recipes.map(recipe => (
80
+ <VentionButton
81
+ key={recipe.id}
82
+ size="x-large"
83
+ variant={recipe.id === editingId ? "filled" : "outline"}
84
+ onClick={() => edit(recipe)}
85
+ >
86
+ {recipe.name}
87
+ </VentionButton>
88
+ ))}
89
+ </Box>
90
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 5, flex: 1, maxWidth: 640 }}>
91
+ <VentionTextInput
92
+ size="xx-large"
93
+ label="Name"
94
+ value={draft.name}
95
+ onChange={event => setDraft({ ...draft, name: event.target.value })}
96
+ />
97
+ {NUMERIC_FIELDS.map(({ field, label, unit, step, min }) => (
98
+ <Box key={field} sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
99
+ <Typography variant="heading18SemiBold">{label}</Typography>
100
+ <VentionStepper
101
+ value={`${draft[field]} ${unit}`.trim()}
102
+ minusButtonProps={{
103
+ disabled: draft[field] - step < min,
104
+ onClick: () => setDraft({ ...draft, [field]: draft[field] - step }),
105
+ }}
106
+ plusButtonProps={{ onClick: () => setDraft({ ...draft, [field]: draft[field] + step }) }}
107
+ />
108
+ </Box>
109
+ ))}
110
+ <Box sx={{ display: "flex", gap: 6 }}>
111
+ <VentionButton
112
+ size="xx-large"
113
+ variant="filled-brand"
114
+ fullWidth
115
+ disabled={!connected || draft.name.trim() === ""}
116
+ onClick={() => void save()}
117
+ >
118
+ Save
119
+ </VentionButton>
120
+ {editingId !== undefined && (
121
+ <VentionButton size="xx-large" variant="destructive" fullWidth disabled={!connected} onClick={() => setConfirmingDelete(true)}>
122
+ Delete
123
+ </VentionButton>
124
+ )}
125
+ </Box>
126
+ </Box>
127
+ <VentionModal
128
+ isOpen={confirmingDelete && connected}
129
+ onClose={() => setConfirmingDelete(false)}
130
+ isTouchDevice
131
+ type="negative"
132
+ titleText={`Delete ${draft.name}?`}
133
+ body="The recipe is removed for every operator."
134
+ primaryButton={{ text: "Delete", variant: "destructive", onClick: () => void remove() }}
135
+ secondaryButton={{ text: "Cancel", onClick: () => setConfirmingDelete(false) }}
136
+ />
137
+ </Box>
138
+ )
139
+ }
@@ -0,0 +1,65 @@
1
+ import { Box, Typography } from "@mui/material"
2
+ import { VentionAlert, VentionSelect, VentionStatusIndicator } from "@vention/machine-ui"
3
+ import { useEffect, useState } from "react"
4
+ import { client } from "../client"
5
+ import type { Recipe, StationStatus } from "../gen/app_pb"
6
+ import { canStart, describeState, errorText } from "../station"
7
+
8
+ interface RunPageProps {
9
+ status: StationStatus | undefined
10
+ connected: boolean
11
+ recipe: Recipe | undefined
12
+ onRecipeChange: (recipe: Recipe | undefined) => void
13
+ onError: (message: string) => void
14
+ }
15
+
16
+ export function RunPage({ status, connected, recipe, onRecipeChange, onError }: RunPageProps) {
17
+ const [recipes, setRecipes] = useState<Recipe[]>([])
18
+ const state = describeState(status, connected)
19
+
20
+ useEffect(() => {
21
+ if (!connected) return
22
+ client.recipe_ListRecords({}).then(
23
+ response => setRecipes(response.records),
24
+ (error: unknown) => onError(`Could not load recipes: ${errorText(error)}`)
25
+ )
26
+ }, [connected, onError])
27
+
28
+ const reset = () => {
29
+ client.trigger_Reset({}).catch((error: unknown) => onError(`Reset failed: ${errorText(error)}`))
30
+ }
31
+
32
+ return (
33
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 6, maxWidth: 960 }}>
34
+ <VentionStatusIndicator size="xx-large" outline dotColor={state.color} label={state.label} />
35
+ {status !== undefined && status.fault !== "" && (
36
+ <VentionAlert
37
+ severity="error"
38
+ size="xx-large"
39
+ title="Fault"
40
+ descriptionText={status.fault}
41
+ primaryButtonText={connected && status.state === "fault" ? "Reset" : undefined}
42
+ onPrimaryButtonClick={reset}
43
+ />
44
+ )}
45
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
46
+ <Typography variant="heading24SemiBold">
47
+ {status?.recipeName ? `${status.recipeName}: cycle ${status.cycle}/${status.cycleCount}` : "No recipe running"}
48
+ </Typography>
49
+ <Typography variant="paragraph18Medium">
50
+ Last cycle: {status && status.lastCycleSeconds > 0 ? `${status.lastCycleSeconds.toFixed(1)} s` : "none yet"}
51
+ </Typography>
52
+ </Box>
53
+ <VentionSelect
54
+ size="xx-large"
55
+ variant="outlined"
56
+ labelText="Recipe"
57
+ placeholder="Choose a recipe"
58
+ value={recipe?.id ?? ""}
59
+ isDisabled={!canStart(status, connected)}
60
+ onChange={event => onRecipeChange(recipes.find(candidate => candidate.id === Number(event.target.value)))}
61
+ menuItems={recipes.map(candidate => ({ value: candidate.id, displayText: candidate.name }))}
62
+ />
63
+ </Box>
64
+ )
65
+ }
@@ -0,0 +1,34 @@
1
+ import { ConnectError } from "@connectrpc/connect"
2
+ import { COLORS } from "@vention/machine-ui"
3
+ import type { StationStatus } from "./gen/app_pb"
4
+
5
+ // The storage RPCs record who changed each recipe.
6
+ export const ACTOR = "operator"
7
+
8
+ type StateDisplay = { label: string; color: string }
9
+
10
+ const STATE_DISPLAY: Record<string, StateDisplay> = {
11
+ ready: { label: "Ready", color: COLORS.slate[500] },
12
+ Station_running: { label: "Running", color: COLORS.green[500] },
13
+ Station_stopping: { label: "Stopping", color: COLORS.amber[500] },
14
+ fault: { label: "Fault", color: COLORS.red[600] },
15
+ }
16
+ const DISCONNECTED: StateDisplay = { label: "Disconnected", color: COLORS.slate[400] }
17
+
18
+ export function describeState(status: StationStatus | undefined, connected: boolean): StateDisplay {
19
+ if (!connected || status === undefined) return DISCONNECTED
20
+ return STATE_DISPLAY[status.state] ?? { label: status.state, color: COLORS.slate[500] }
21
+ }
22
+
23
+ export function canStart(status: StationStatus | undefined, connected: boolean): boolean {
24
+ return connected && status?.state === "ready" && status.fault === ""
25
+ }
26
+
27
+ /** Stop is disabled only when the station is known to be ready: a dropped stream may hide a moving conveyor. */
28
+ export function canStop(status: StationStatus | undefined, connected: boolean): boolean {
29
+ return !(connected && status?.state === "ready")
30
+ }
31
+
32
+ export function errorText(error: unknown): string {
33
+ return ConnectError.from(error).rawMessage
34
+ }
@@ -0,0 +1,54 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ const RETRY_DELAYS_MS = [500, 1000, 2000, 5000]
4
+
5
+ /**
6
+ * Latest message of a server stream, reopened whenever it drops; pass a stable `open`.
7
+ * `stallMs` treats silence as a drop to catch hung connections; use it only on heartbeat streams.
8
+ */
9
+ export function useStream<T>(
10
+ open: (signal: AbortSignal) => AsyncIterable<T>,
11
+ stallMs?: number
12
+ ): { value: T | undefined; connected: boolean } {
13
+ const [value, setValue] = useState<T | undefined>(undefined)
14
+ const [connected, setConnected] = useState(false)
15
+
16
+ useEffect(() => {
17
+ const controller = new AbortController()
18
+ let attempt = 0
19
+ const consume = async (): Promise<void> => {
20
+ while (!controller.signal.aborted) {
21
+ const streamController = new AbortController()
22
+ const abortStream = () => streamController.abort()
23
+ controller.signal.addEventListener("abort", abortStream)
24
+ let stallTimer: ReturnType<typeof setTimeout> | undefined
25
+ const armStallTimer = () => {
26
+ clearTimeout(stallTimer)
27
+ if (stallMs !== undefined) stallTimer = setTimeout(abortStream, stallMs)
28
+ }
29
+ armStallTimer()
30
+ try {
31
+ for await (const message of open(streamController.signal)) {
32
+ attempt = 0
33
+ armStallTimer()
34
+ setValue(message)
35
+ setConnected(true)
36
+ }
37
+ } catch {
38
+ // A dropped or stalled stream is reopened below.
39
+ }
40
+ clearTimeout(stallTimer)
41
+ controller.signal.removeEventListener("abort", abortStream)
42
+ if (controller.signal.aborted) return
43
+ setConnected(false)
44
+ const delay = RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)]
45
+ attempt += 1
46
+ await new Promise(resolve => setTimeout(resolve, delay))
47
+ }
48
+ }
49
+ void consume()
50
+ return () => controller.abort()
51
+ }, [open, stallMs])
52
+
53
+ return { value, connected }
54
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "allowJs": false,
19
+ "esModuleInterop": false,
20
+ "allowSyntheticDefaultImports": true
21
+ },
22
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
23
+ "exclude": ["node_modules", "dist"]
24
+ }
@@ -0,0 +1,13 @@
1
+ from typing import Optional
2
+
3
+ from sqlmodel import Field, SQLModel
4
+
5
+
6
+ # The HMI's Connect client leaves zero values out of the JSON it sends, so every field needs a zero default.
7
+ class Recipe(SQLModel, table=True):
8
+ id: Optional[int] = Field(default=None, primary_key=True)
9
+ name: str = ""
10
+ index_distance_mm: float = 0.0
11
+ speed_mm_s: float = 0.0
12
+ dwell_ms: int = 0
13
+ cycle_count: int = 0
@@ -0,0 +1,6 @@
1
+ {
2
+ "processes": {
3
+ "onPlay": [{ "name": "indexing-station", "command": "python3 -m uvicorn server:app --host 0.0.0.0 --port 8000", "port": 8000 }]
4
+ },
5
+ "ui": { "showDefaultBackToControlCenterButton": true }
6
+ }