@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,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
|
+
}
|
package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/station.ts
ADDED
|
@@ -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
|
+
}
|
package/skills/vention-machine-logic-hmi/examples/indexing-station-hmi/customui/src/use-stream.ts
ADDED
|
@@ -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
|