@vention/vention-skills 0.10.14 → 0.10.16

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vention",
3
- "version": "0.10.14",
3
+ "version": "0.10.16",
4
4
  "description": "Vention skills for coding agents: write MachineLogic applications, build machine designs, and diagnose deployed machines.",
5
5
  "author": {
6
6
  "name": "Vention"
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vention skills
2
2
 
3
- Version 0.10.14. Skills for coding agents that work with Vention machines, packaged as one plugin, `vention`, in the `vention-skills` marketplace.
3
+ Version 0.10.16. Skills for coding agents that work with Vention machines, packaged as one plugin, `vention`, in the `vention-skills` marketplace.
4
4
 
5
5
  | Skill | What it does |
6
6
  | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vention/vention-skills",
3
- "version": "0.10.14",
3
+ "version": "0.10.16",
4
4
  "description": "Vention skills for coding agents: write MachineLogic applications, build machine designs, and diagnose deployed machines.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "vention",
4
- "version": "0.10.14",
4
+ "version": "0.10.16",
5
5
  "description": "Vention skills for coding agents: write MachineLogic applications, build machine designs, and diagnose deployed machines.",
6
6
  "author": {
7
7
  "name": "Vention"
@@ -3,7 +3,7 @@ name: vention-design
3
3
  description: Create, build and edit Vention machine designs and design graphs for agentic layout. Use when the user wants to start a new design, lay out an automation cell, add or reposition components in a Vention design, generate a machine layout from a description, add or place a floor plan image under a design, list a design's versions, or inspect an existing design graph. Trigger on MachineBuilder designs, design graphs, assembly graphs, extrusions, actuators, robot mounting, cell footprints, floor plans, or a machine-builder URL.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.10.14"
6
+ version: "0.10.16"
7
7
  author: Vention
8
8
  ---
9
9
 
@@ -3,7 +3,7 @@ name: vention-machine-logic
3
3
  description: Write, edit, validate, and deploy MachineLogic Python applications for Vention machines with the machine-logic-sdk and the Vention CLI. Load it before writing or changing any Python that talks to a Vention machine, however short, since device names and version-specific SDK calls come from here. Use to program a Vention machine or MachineMotion controller, drive actuators, conveyors, robots, pneumatics, or digital I/O, change an existing application, build a state machine, debug from logs, or pull, push, or link with the Vention CLI (vention or vn). Trigger on MachineLogic, machine-logic-sdk, MachineMotion, MachineBuilder applications, the Vention CLI, or a directory holding .machine-code-app-directory-info.json. Printing what the machine or robot holds now (positions, taught targets, calibration, I/O) is a program: files on disk hold only as-designed values. Not for explaining an existing machine or its configuration in words when no program is asked for, nor for Python that never touches the machine.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.10.14"
6
+ version: "0.10.16"
7
7
  machine-logic-sdk: ">=3.0,<4"
8
8
  author: Vention
9
9
  ---
@@ -245,7 +245,7 @@ Each example is a standalone `main.py` whose module docstring names the device n
245
245
  4. If `uv` is missing or the install fails, tell the user the code is unvalidated. Never report a check you could not run as passed.
246
246
  5. `vention push`, then run the application on the digital twin from MachineBuilder before any hardware run. One or two full cycles prove the cycle; each extra one costs a cycle time and shows nothing the first two did not. Run them at 100 % of the speed setting, chosen for that run: nothing on the twin can be hurt.
247
247
  6. Read the output with `vention logs` once the run ends, rather than following it while the cycles play. `vention logs` prints only the most recent lines, so a count of matching lines can stop rising before the run ends. Print each cycle's measured time. When the cycle time is unknown, sleep once for one cycle's estimate, read `vention logs` once for the measured time, then sleep once for the cycles left: a rounded-up guess for the whole run wastes the difference. Before that first sleep, read `vention logs` once a few seconds after starting the run, to see that the first cycle began: a run stuck before its first move would otherwise cost the whole sleep. Print a line before every call that can block for long, such as a home or a long move, not only after it, so a log that stops shows which call it stopped in. Never loop over `vention logs` waiting for a line to appear, and never end your turn while a background wait is running. MachineLogic failures name the device and the reason; read the log before proposing a change.
248
- 7. Check a motion from the log, not by watching the twin: `print` the robot's joint angles or the axis position at each step, then compare them with what the step should reach. For a repeated or oscillating motion, such as a wave, print the joint angles at each end of the swing, so the log alone shows how far it moved and which joints moved.
248
+ 7. Check a motion from the log, not by watching the twin: `print` the robot's joint angles (`angles, _timestamp = robot.state.joint_angles_data`) or the axis position at each step, then compare them with what the step should reach. For a repeated or oscillating motion, such as a wave, print the joint angles at each end of the swing, so the log alone shows how far it moved and which joints moved.
249
249
  8. On the digital twin, `home()` can hang: it does not return, even past its timeout, because the SDK keeps retrying the request. The twin also reports an axis's end sensors as off at every position, so their state cannot tell you where the axis is. Never delete the homing to get past a hang: the hardware needs it. Stop the run, tell the user the home did not finish on the twin, and ask whether to skip the home on the twin only.
250
250
  9. Pressing the emergency stop on the digital twin's pendant does nothing, so skip that test there. Tell the user the e-stop path is untested until the first run on the real cell.
251
251
  10. Show the user the diff before pushing over an application they did not write in this session.
@@ -257,6 +257,8 @@ The controller keeps its own scene. Positions re-taught on the pendant and the c
257
257
  - Deploy over the LAN from the application's Deploy menu in MachineBuilder with only the application ticked. "Design-level Scene Assets" replaces the controller's positions with the design's, including every one re-taught on the pendant, and "Configuration" replaces its configuration.
258
258
  - When the application needs a position the controller lacks, ask the user to teach it on the pendant under the same name it has on the design.
259
259
  - Deploying restarts the application, so deploy only while the cell is idle.
260
+ - Every start installs `requirements.txt` from PyPI, so the controller needs internet access; without it the install fails and the application never starts.
261
+ - After a deploy, confirm the application started before telling the user it runs. `curl -s http://<controller>/execution-engine/v1/application` returns its `state` and, under `logging`, the install's and the application's logs (parse it with `json.loads(text, strict=False)`: the log text holds raw control characters). A `completed` state with an install error in the logs means it never ran; tell the user what failed.
260
262
  - `print` what someone reading the controller's log needs, such as a step starting, a part finished or a fault. An HMI's event stream does not reach the log.
261
263
 
262
264
  ## Safety
@@ -3,7 +3,7 @@ name: vention-machine-logic-hmi
3
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
4
  license: MIT
5
5
  metadata:
6
- version: "0.10.14"
6
+ version: "0.10.16"
7
7
  author: Vention
8
8
  ---
9
9
 
@@ -84,7 +84,7 @@ Blocking SDK calls, cancellation and the shutdown path follow the `vention-machi
84
84
 
85
85
  - Start the cycle from an `@on_enter_state` handler with `self.spawn(...)`.
86
86
  - Never publish a stream or fire a trigger from inside a worker thread. Return to the loop first.
87
- - 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.
87
+ - 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. `TO_FAULT` does not cancel what `spawn` started. Route the emergency stop from `machine.on_system_state_change` to fault after `await station.cancel_tasks()`, and stop every device in the `lifespan`'s shutdown, as the example's `fault_on_estop` and `stop_devices` do.
88
88
 
89
89
  ## Live data is a stream
90
90
 
@@ -165,14 +165,14 @@ The machine-apps-components README is behind its code: `NavigationBar` takes `Na
165
165
  - Confirm anything that starts motion or deletes data in a `VentionModal` with `isTouchDevice`, and say in it what will move.
166
166
  - `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.
167
167
  - Start is green and Stop is red, both set through the `StatusTopBar` button's `backgroundColor`, `backgroundColorHover` and `textColor`.
168
- - 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`.
168
+ - 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`. Log the HMI's own command failures there too, beside the server's events. Raise every error as a banner on whatever page is open, whose View button opens that entry on the Logs page; the banner fades after a few seconds, and the Logs tab shows a red count of the errors logged since that page was last opened, as the example's `app.tsx` does. Alerts float over the page just under the `StatusTopBar`, never in the page's flow, so one appearing never moves the layout.
169
169
 
170
170
  ## Validate before you push
171
171
 
172
172
  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.
173
173
  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.
174
174
  3. `vention push`, then open the HMI from the MachineLogic tab in MachineBuilder and run it on the digital twin before any hardware run. Inside the MachineBuilder tab the browser's element search can't see the HMI's controls, so take a screenshot and tap by coordinates, and take a fresh screenshot after each tap since the layout moves.
175
- 4. Read `vention logs` when a command fails; the server's errors land there. On a real controller, read them as the `vention-machine-logic` skill's "Deploying to a real cell" says.
175
+ 4. Read `vention logs` when a command fails; the server's errors land there. On a real controller, confirm the application started and read its logs as the `vention-machine-logic` skill's "Deploying to a real cell" says.
176
176
 
177
177
  ## Safety
178
178
 
@@ -180,7 +180,7 @@ The HMI drives physical machinery that can injure someone.
180
180
 
181
181
  - Start never fires on page load, on reconnect, or from a stream message. Only an operator's tap, confirmed in a modal, starts motion.
182
182
  - 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.
183
- - Stop stays enabled unless the station is known to be ready or already stopping, as the example's `canStop` does. A fault is neither, so Stop stays enabled there, and so it does while the stream is disconnected: a dropped stream may hide a moving machine. Every other command is disabled while disconnected.
183
+ - Stop is disabled until this page load has received its first status, since no Stop can reach the app before then; show why next to it and say to use the emergency stop if the machine moves. After that, Stop stays enabled unless the station is known to be ready or already stopping, as the example's `canStop` does. A fault is neither, so Stop stays enabled there, and so it does while the stream is disconnected: a dropped stream may hide a moving machine. When Stop fails, say it did not reach the station and to use the emergency stop, in an alert that stays until the operator closes it. Every other command is disabled while disconnected.
184
184
  - A tap on Stop shows at once. While the station stops, the Stop button reads "Stopping…" (derive that label from the streamed state and `connected`, as the example's `isStopping(status, connected)` does, never from a tapped flag alone: once the stream drops it reads "Stop" again and stays pressable), the Operate page's progress bar marks where the run now ends (the cycle in progress amber, the cancelled ones hatched and struck through), and a line by the heading says the emergency stop halts at once. Say it on the screen the operator already watches rather than in a separate alert.
185
185
  - 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.
186
186
  - The HMI never offers a way around an emergency stop, safety interlock, light curtain or guard, not even a hidden one for testing.
@@ -190,7 +190,7 @@ The HMI drives physical machinery that can injure someone.
190
190
 
191
191
  `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.
192
192
 
193
- - `server.py`: the state machine, the streams, the Start action, both bundles, and `Machine()` in the `lifespan`.
193
+ - `server.py`: the state machine, the streams, the Start action, both bundles, `Machine()` in the `lifespan`, the emergency stop routed to fault, and every device stopped on shutdown.
194
194
  - `models.py`: the `Recipe` storage model, every field with a zero default.
195
195
  - `project.json`, `requirements.txt`: the one process and the exact pins.
196
196
  - `proto/app.proto`: what `finalize()` emits.
@@ -216,7 +216,7 @@ The HMI drives physical machinery that can injure someone.
216
216
  - `size` left at the `"small"` default on a touch screen.
217
217
  - `BrowserRouter`, whose routes miss under `.../index.html`.
218
218
  - Reading versions from `ui/package.json`; the manifest is `customui/package.json`.
219
- - Disabling Stop whenever the stream drops.
219
+ - Disabling Stop whenever the stream drops, or enabling it before the page has ever connected.
220
220
  - A Stop that looks the same before and after the tap while the station finishes its move.
221
221
  - The state shown in the `StatusTopBar` and again on a page.
222
222
  - `severity="warning"` on a `VentionAlert`. It is `"warn"`.
@@ -2,9 +2,9 @@ import { Box } from "@mui/material"
2
2
  import { IconBook, IconHistory, IconLayoutDashboard, IconPlayerPlayFilled, IconPlayerStopFilled } from "@tabler/icons-react"
3
3
  import { NavigationBar, StatusTopBar } from "@vention/machine-apps-components"
4
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"
5
+ import { COLORS, VentionAlert, VentionCounter, VentionModal } from "@vention/machine-ui"
6
+ import { useCallback, useEffect, useRef, useState } from "react"
7
+ import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
8
8
  import { client, openEvents, openStatus, STATUS_STALL_MS } from "./client"
9
9
  import type { Event, Recipe } from "./gen/app_pb"
10
10
  import { LogsPage } from "./pages/logs-page"
@@ -19,6 +19,8 @@ const NAV_ICON_SIZE = 42
19
19
  const TOP_BAR_ICON_SIZE = 32
20
20
  // A Start that has not been answered by then is abandoned rather than left to land later.
21
21
  const START_TIMEOUT_MS = 5000
22
+ // The Logs tab's unread count keeps an error after its banner fades.
23
+ const ERROR_BANNER_MS = 8000
22
24
 
23
25
  function toLogLevel(level: string): LogType {
24
26
  if (level === "error") return "error"
@@ -26,32 +28,40 @@ function toLogLevel(level: string): LogType {
26
28
  return "info"
27
29
  }
28
30
 
29
- function useEventLog(): LogEntry[] {
31
+ /** The server's events, plus the HMI's own command failures, newest first. */
32
+ function useEventLog(): { entries: LogEntry[]; addEntry: (level: LogType, message: string, description: string) => string } {
30
33
  const { value: latestEvent } = useStream<Event>(openEvents)
31
34
  const [entries, setEntries] = useState<LogEntry[]>([])
32
35
  const nextId = useRef(0)
33
36
 
34
- useEffect(() => {
35
- if (latestEvent === undefined) return
37
+ const addEntry = useCallback((level: LogType, message: string, description: string, date = new Date().toISOString()) => {
36
38
  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
- }
39
+ const entry: LogEntry = { id: String(nextId.current), date, level, code: level.toUpperCase(), message, description }
45
40
  setEntries(previous => [entry, ...previous].slice(0, MAX_LOG_ENTRIES))
46
- }, [latestEvent])
41
+ return entry.id
42
+ }, [])
43
+
44
+ useEffect(() => {
45
+ if (latestEvent !== undefined) addEntry(toLogLevel(latestEvent.level), latestEvent.message, latestEvent.detail, latestEvent.timestamp)
46
+ }, [latestEvent, addEntry])
47
47
 
48
- return entries
48
+ return { entries, addEntry }
49
49
  }
50
50
 
51
51
  export function App() {
52
52
  const { value: status, connected } = useStream(openStatus, STATUS_STALL_MS)
53
- const logEntries = useEventLog()
54
- const [commandError, setCommandError] = useState("")
53
+ const { entries: logEntries, addEntry } = useEventLog()
54
+ const navigate = useNavigate()
55
+ const isOnLogsPage = useLocation().pathname === "/logs"
56
+ // The latest error shows as a banner on every page until it fades; View opens the whole entry on the Logs page.
57
+ const [errorEntry, setErrorEntry] = useState<LogEntry | undefined>(undefined)
58
+ const [openLogEntry, setOpenLogEntry] = useState<LogEntry | undefined>(undefined)
59
+ // A Stop that did not land never fades: the operator must know to use the emergency stop.
60
+ const [stopFailure, setStopFailure] = useState("")
61
+ const stopFailureIds = useRef(new Set<string>())
62
+ const [lastSeenLogId, setLastSeenLogId] = useState(0)
63
+ const latestEntry = logEntries[0]
64
+ const unseenErrors = logEntries.filter(entry => entry.level === "error" && Number(entry.id) > lastSeenLogId).length
55
65
  const state = describeState(status, connected)
56
66
 
57
67
  const [recipe, setRecipe] = useState<Recipe | undefined>(undefined)
@@ -59,10 +69,32 @@ export function App() {
59
69
  const startRequest = useRef<AbortController | undefined>(undefined)
60
70
  const startable = canStart(status, connected) && recipe !== undefined
61
71
 
72
+ const reportError = useCallback((message: string, detail: string) => void addEntry("error", message, detail), [addEntry])
73
+
62
74
  useEffect(() => {
63
75
  if (!connected) startRequest.current?.abort()
64
76
  }, [connected])
65
77
 
78
+ useEffect(() => {
79
+ if (latestEntry?.level === "error" && !stopFailureIds.current.has(latestEntry.id)) setErrorEntry(latestEntry)
80
+ }, [latestEntry])
81
+
82
+ useEffect(() => {
83
+ if (errorEntry === undefined) return
84
+ const timer = setTimeout(() => setErrorEntry(undefined), ERROR_BANNER_MS)
85
+ return () => clearTimeout(timer)
86
+ }, [errorEntry])
87
+
88
+ useEffect(() => {
89
+ if (isOnLogsPage && latestEntry !== undefined) setLastSeenLogId(Number(latestEntry.id))
90
+ }, [isOnLogsPage, latestEntry])
91
+
92
+ const viewError = () => {
93
+ setOpenLogEntry(errorEntry)
94
+ setErrorEntry(undefined)
95
+ navigate("/logs")
96
+ }
97
+
66
98
  const start = async () => {
67
99
  setConfirmingStart(false)
68
100
  if (recipe === undefined) return
@@ -70,14 +102,18 @@ export function App() {
70
102
  startRequest.current = controller
71
103
  try {
72
104
  const response = await client.start({ recipeId: recipe.id }, { signal: controller.signal, timeoutMs: START_TIMEOUT_MS })
73
- if (!response.accepted) setCommandError(`Start refused: ${response.message}`)
105
+ if (!response.accepted) reportError("Start refused", response.message)
74
106
  } catch (error) {
75
- setCommandError(`Start not confirmed: ${errorText(error)}. Check the station state before starting again.`)
107
+ reportError("Start not confirmed", `${errorText(error)}. Check the station state before starting again.`)
76
108
  }
77
109
  }
78
110
 
79
111
  const stop = () => {
80
- client.trigger_Stop({}).catch((error: unknown) => setCommandError(`Stop failed: ${errorText(error)}`))
112
+ client.trigger_Stop({}).catch((error: unknown) => {
113
+ const detail = errorText(error)
114
+ stopFailureIds.current.add(addEntry("error", "Stop did not reach the station", `${detail}. Use the emergency stop.`))
115
+ setStopFailure(`Stop did not reach the station (${detail}). Use the emergency stop.`)
116
+ })
81
117
  }
82
118
 
83
119
  return (
@@ -110,34 +146,87 @@ export function App() {
110
146
  },
111
147
  ]}
112
148
  />
149
+ {/* Alerts float over the page, so one appearing never moves the page; the gaps between them stay tappable. */}
150
+ <Box
151
+ sx={{
152
+ position: "absolute",
153
+ top: "100%",
154
+ left: 0,
155
+ right: 0,
156
+ padding: 4,
157
+ display: "flex",
158
+ flexDirection: "column",
159
+ gap: 2,
160
+ pointerEvents: "none",
161
+ "& > *": { pointerEvents: "auto" },
162
+ }}
163
+ >
164
+ {status === undefined && (
165
+ <VentionAlert
166
+ severity="warn"
167
+ size="xx-large"
168
+ title="Not connected to the app yet"
169
+ descriptionText="Stop is available once the app answers. Use the emergency stop if the machine moves."
170
+ isFullWidth
171
+ appearance="elevated"
172
+ />
173
+ )}
174
+ {errorEntry !== undefined && (
175
+ <VentionAlert
176
+ severity="error"
177
+ size="xx-large"
178
+ title={errorEntry.message}
179
+ primaryButtonText="View"
180
+ onPrimaryButtonClick={viewError}
181
+ isFullWidth
182
+ appearance="elevated"
183
+ isCloseable
184
+ onCloseButtonClick={() => setErrorEntry(undefined)}
185
+ />
186
+ )}
187
+ {stopFailure !== "" && (
188
+ <VentionAlert
189
+ severity="error"
190
+ size="xx-large"
191
+ title={stopFailure}
192
+ isFullWidth
193
+ appearance="elevated"
194
+ isCloseable
195
+ onCloseButtonClick={() => setStopFailure("")}
196
+ />
197
+ )}
198
+ </Box>
113
199
  </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
200
  <Box sx={{ flex: 1, minHeight: 0, overflow: "auto", padding: 6 }}>
125
201
  <Routes>
126
202
  <Route
127
203
  path="/"
128
- element={
129
- <OperatePage status={status} connected={connected} recipe={recipe} onRecipeChange={setRecipe} onError={setCommandError} />
130
- }
204
+ element={<OperatePage status={status} connected={connected} recipe={recipe} onRecipeChange={setRecipe} onError={reportError} />}
131
205
  />
132
- <Route path="/recipes" element={<RecipesPage connected={connected} onError={setCommandError} />} />
133
- <Route path="/logs" element={<LogsPage entries={logEntries} />} />
206
+ <Route path="/recipes" element={<RecipesPage connected={connected} onError={reportError} />} />
207
+ <Route path="/logs" element={<LogsPage entries={logEntries} openEntry={openLogEntry} onOpenEntry={setOpenLogEntry} />} />
134
208
  <Route path="*" element={<Navigate to="/" replace />} />
135
209
  </Routes>
136
210
  </Box>
137
211
  <NavigationBar variant="flex">
138
212
  <NavigationBar.Item id="operate" label="Operate" path="/" icon={<IconLayoutDashboard size={NAV_ICON_SIZE} color="white" />} />
139
213
  <NavigationBar.Item id="recipes" label="Recipes" path="/recipes" icon={<IconBook size={NAV_ICON_SIZE} color="white" />} />
140
- <NavigationBar.Item id="logs" label="Logs" path="/logs" icon={<IconHistory size={NAV_ICON_SIZE} color="white" />} />
214
+ <NavigationBar.Item
215
+ id="logs"
216
+ label="Logs"
217
+ path="/logs"
218
+ icon={
219
+ // NavigationBar.Item has no badge, so the unread count sits on the icon's corner.
220
+ <Box sx={{ position: "relative", display: "flex" }}>
221
+ <IconHistory size={NAV_ICON_SIZE} color="white" />
222
+ {unseenErrors > 0 && (
223
+ <Box sx={{ position: "absolute", top: -8, right: -12 }}>
224
+ <VentionCounter color="red" variant="filled" size="large" value={unseenErrors} />
225
+ </Box>
226
+ )}
227
+ </Box>
228
+ }
229
+ />
141
230
  </NavigationBar>
142
231
  <VentionModal
143
232
  isOpen={confirmingStart && startable}
@@ -2,7 +2,7 @@ import { LogsPanel } from "@vention/machine-apps-components"
2
2
  import type { FetchParams, FetchResult, LogEntry, LogType } from "@vention/machine-apps-components"
3
3
  import { VentionModal } from "@vention/machine-ui"
4
4
  import type { VentionModalProps } from "@vention/machine-ui"
5
- import { useCallback, useState } from "react"
5
+ import { useCallback } from "react"
6
6
 
7
7
  const PAGE_SIZE = 10
8
8
  const MODAL_TYPE: Record<LogType, VentionModalProps["type"]> = { error: "negative", warning: "warning", info: "info" }
@@ -15,8 +15,14 @@ function matchesFilters(entry: LogEntry, { filters }: FetchParams): boolean {
15
15
  return true
16
16
  }
17
17
 
18
- export function LogsPage({ entries }: { entries: LogEntry[] }) {
19
- const [openEntry, setOpenEntry] = useState<LogEntry | undefined>(undefined)
18
+ type LogsPageProps = {
19
+ entries: LogEntry[]
20
+ // Held by the app, so the error banner can open its entry here.
21
+ openEntry: LogEntry | undefined
22
+ onOpenEntry: (entry: LogEntry | undefined) => void
23
+ }
24
+
25
+ export function LogsPage({ entries, openEntry, onOpenEntry: setOpenEntry }: LogsPageProps) {
20
26
  const dataFetcher = useCallback(
21
27
  async (params: FetchParams): Promise<FetchResult> => {
22
28
  const matching = entries.filter(entry => matchesFilters(entry, params))
@@ -10,7 +10,7 @@ interface OperatePageProps {
10
10
  connected: boolean
11
11
  recipe: Recipe | undefined
12
12
  onRecipeChange: (recipe: Recipe | undefined) => void
13
- onError: (message: string) => void
13
+ onError: (message: string, detail: string) => void
14
14
  }
15
15
 
16
16
  /** What the page shows. The same regions render in every phase; only their content changes. */
@@ -202,12 +202,12 @@ export function OperatePage({ status, connected, recipe, onRecipeChange, onError
202
202
  if (!connected) return
203
203
  client.recipe_ListRecords({}).then(
204
204
  response => setRecipes(response.records),
205
- (error: unknown) => onError(`Could not load recipes: ${errorText(error)}`)
205
+ (error: unknown) => onError("Could not load recipes", errorText(error))
206
206
  )
207
207
  }, [connected, onError])
208
208
 
209
209
  const reset = () => {
210
- client.trigger_Reset({}).catch((error: unknown) => onError(`Reset failed: ${errorText(error)}`))
210
+ client.trigger_Reset({}).catch((error: unknown) => onError("Reset failed", errorText(error)))
211
211
  }
212
212
 
213
213
  return (
@@ -19,7 +19,7 @@ const NUMERIC_FIELDS: { field: NumericField; label: string; unit: string; step:
19
19
 
20
20
  interface RecipesPageProps {
21
21
  connected: boolean
22
- onError: (message: string) => void
22
+ onError: (message: string, detail: string) => void
23
23
  }
24
24
 
25
25
  export function RecipesPage({ connected, onError }: RecipesPageProps) {
@@ -32,7 +32,7 @@ export function RecipesPage({ connected, onError }: RecipesPageProps) {
32
32
  try {
33
33
  setRecipes((await client.recipe_ListRecords({})).records)
34
34
  } catch (error) {
35
- onError(`Could not load recipes: ${errorText(error)}`)
35
+ onError("Could not load recipes", errorText(error))
36
36
  }
37
37
  }, [onError])
38
38
 
@@ -54,7 +54,7 @@ export function RecipesPage({ connected, onError }: RecipesPageProps) {
54
54
  setEditingId(response.record?.id)
55
55
  await reload()
56
56
  } catch (error) {
57
- onError(`Could not save ${draft.name}: ${errorText(error)}`)
57
+ onError(`Could not save ${draft.name}`, errorText(error))
58
58
  }
59
59
  }
60
60
 
@@ -66,7 +66,7 @@ export function RecipesPage({ connected, onError }: RecipesPageProps) {
66
66
  edit(undefined)
67
67
  await reload()
68
68
  } catch (error) {
69
- onError(`Could not delete ${draft.name}: ${errorText(error)}`)
69
+ onError(`Could not delete ${draft.name}`, errorText(error))
70
70
  }
71
71
  }
72
72
 
@@ -22,10 +22,13 @@ const STATE_DISPLAY: Record<string, StateDisplay> = {
22
22
  Station_stopping: { label: "Stopping", color: COLORS.amber[500] },
23
23
  fault: { label: "Fault", color: COLORS.red[600] },
24
24
  }
25
+ const CONNECTING: StateDisplay = { label: "Connecting…", color: COLORS.slate[400] }
25
26
  const DISCONNECTED: StateDisplay = { label: "Disconnected", color: COLORS.slate[400] }
26
27
 
28
+ // `status` stays set once the stream has delivered, so undefined means this page has never connected.
27
29
  export function describeState(status: StationStatus | undefined, connected: boolean): StateDisplay {
28
- if (!connected || status === undefined) return DISCONNECTED
30
+ if (status === undefined) return CONNECTING
31
+ if (!connected) return DISCONNECTED
29
32
  return STATE_DISPLAY[status.state] ?? { label: status.state, color: COLORS.slate[500] }
30
33
  }
31
34
 
@@ -37,9 +40,12 @@ export function isStopping(status: StationStatus | undefined, connected: boolean
37
40
  return connected && status?.state === "Station_stopping"
38
41
  }
39
42
 
40
- /** Stop is disabled only when the station is known to be ready or already stopping: a dropped stream may hide a moving conveyor. */
43
+ /**
44
+ * Stop waits for the first connection, since before it no Stop can reach the app. After that it is disabled only
45
+ * when the station is known to be ready or already stopping: a dropped stream may hide a moving conveyor.
46
+ */
41
47
  export function canStop(status: StationStatus | undefined, connected: boolean): boolean {
42
- return !(connected && status?.state === "ready") && !isStopping(status, connected)
48
+ return status !== undefined && !(connected && status.state === "ready") && !isStopping(status, connected)
43
49
  }
44
50
 
45
51
  export function errorText(error: unknown): string {
@@ -1,6 +1,7 @@
1
1
  """Indexing station with an operator HMI: index, clamp, prove the clamp, dwell, release, per recipe cycle.
2
2
 
3
3
  Needs actuator "Conveyor", output "Clamp" and input "Clamp Closed"; customui/ calls it only through the generated client.
4
+ An emergency stop sends the station to fault, and shutting the server down stops the conveyor and opens the clamp.
4
5
  """
5
6
 
6
7
  import asyncio
@@ -16,6 +17,7 @@ from pydantic import BaseModel
16
17
  from communication.app import VentionApp
17
18
  from communication.decorators import action, stream
18
19
  from machinelogic import Machine, MotionProfile
20
+ from machinelogic.ivention.imachine import MachineOperationalState, MachineSafetyState
19
21
  from state_machine.core import BaseStates, BaseTriggers, StateMachine
20
22
  from state_machine.decorators import on_enter_state, on_state_change
21
23
  from state_machine.defs import State, StateGroup, Trigger
@@ -155,6 +157,20 @@ class IndexingStation(StateMachine):
155
157
  if self.machine is not None:
156
158
  self.machine.get_output("Clamp").write(False)
157
159
 
160
+ def stop_devices(self) -> None:
161
+ if self.machine is not None:
162
+ self.machine.get_actuator("Conveyor").stop()
163
+ self.release()
164
+
165
+ async def fault_on_estop(self) -> None:
166
+ # The to_fault trigger does not cancel the cycle task, so cancel it first.
167
+ await self.cancel_tasks()
168
+ self.fault_message = "emergency stop pressed"
169
+ self.trigger(BaseTriggers.TO_FAULT.value)
170
+ await publish_event(
171
+ "error", "Emergency stop", "Release the emergency stop and check the station, then press Reset on the Operate page."
172
+ )
173
+
158
174
  async def release_safely(self) -> None:
159
175
  try:
160
176
  await asyncio.to_thread(self.release)
@@ -218,14 +234,26 @@ async def repeat_status() -> None:
218
234
 
219
235
  @asynccontextmanager
220
236
  async def lifespan(_: FastAPI) -> AsyncIterator[None]:
237
+ loop = asyncio.get_running_loop()
238
+
239
+ def on_safety(_operational: MachineOperationalState, safety: MachineSafetyState) -> None:
240
+ if safety == MachineSafetyState.EMERGENCY_STOP:
241
+ asyncio.run_coroutine_threadsafe(station.fault_on_estop(), loop)
242
+
221
243
  try:
222
244
  station.machine = await asyncio.to_thread(Machine)
245
+ station.machine.on_system_state_change(on_safety)
223
246
  except Exception as error:
224
247
  station.fault_message = f"cannot reach the controller: {error}"
225
248
  await publish_status()
226
249
  heartbeat = asyncio.create_task(repeat_status())
227
- yield
228
- heartbeat.cancel()
250
+ try:
251
+ yield
252
+ finally:
253
+ heartbeat.cancel()
254
+ # Cancelling the cycle does not stop a move already running in its thread.
255
+ await station.cancel_tasks()
256
+ await asyncio.to_thread(station.stop_devices)
229
257
 
230
258
 
231
259
  app = VentionApp(name="IndexingStation", emit_proto=True, proto_path="proto/app.proto", lifespan=lifespan)
@@ -3,7 +3,7 @@ name: vention-monitoring
3
3
  description: Diagnose deployed Vention machines from controller state, history, health, and activity data. Use when the user reports a machine that is down, faulted, offline, slow, or underperforming, asks why a cycle time regressed, wants to interpret MachineMotion event data or drive and brake status, asks about OEE or uptime, or wants to know which machine in a fleet needs attention. Trigger on a MachineMotion serial number plus any complaint.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.10.14"
6
+ version: "0.10.16"
7
7
  author: Vention
8
8
  ---
9
9