@fleetless/contracts 1.0.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/CHANGELOG.md +26 -0
- package/LICENSE +202 -0
- package/NOTICE +17 -0
- package/README.md +88 -0
- package/artifacts/constants.json +24 -0
- package/artifacts/openapi.json +17219 -0
- package/artifacts/routes.json +4605 -0
- package/artifacts/schema/accept-team-invite-request.schema.json +22 -0
- package/artifacts/schema/action-config.schema.json +198 -0
- package/artifacts/schema/alert-list-response.schema.json +172 -0
- package/artifacts/schema/api-error.schema.json +20 -0
- package/artifacts/schema/app-auth-config.schema.json +106 -0
- package/artifacts/schema/app-invitation-list-response.schema.json +57 -0
- package/artifacts/schema/app-invitation.schema.json +69 -0
- package/artifacts/schema/app-list-response.schema.json +82 -0
- package/artifacts/schema/app-mail-template-list-response.schema.json +68 -0
- package/artifacts/schema/app-mail-template.schema.json +54 -0
- package/artifacts/schema/app-oidc-provider-list-response.schema.json +93 -0
- package/artifacts/schema/app-oidc-provider.schema.json +80 -0
- package/artifacts/schema/app-user-list-response.schema.json +111 -0
- package/artifacts/schema/app-user.schema.json +98 -0
- package/artifacts/schema/app.schema.json +69 -0
- package/artifacts/schema/apply-error.schema.json +41 -0
- package/artifacts/schema/asset-list-response.schema.json +288 -0
- package/artifacts/schema/asset-sync-request.schema.json +17 -0
- package/artifacts/schema/asset-sync-response.schema.json +16 -0
- package/artifacts/schema/asset-sync-status.schema.json +136 -0
- package/artifacts/schema/asset.schema.json +68 -0
- package/artifacts/schema/audit-actor.schema.json +32 -0
- package/artifacts/schema/audit-event.schema.json +119 -0
- package/artifacts/schema/audit-list-response.schema.json +144 -0
- package/artifacts/schema/audit-query.schema.json +79 -0
- package/artifacts/schema/auth-error.schema.json +23 -0
- package/artifacts/schema/auth-me-response.schema.json +99 -0
- package/artifacts/schema/auth-ok.schema.json +115 -0
- package/artifacts/schema/authorization-server-metadata.schema.json +80 -0
- package/artifacts/schema/bridge-asset-progress.schema.json +99 -0
- package/artifacts/schema/bridge-assets-available.schema.json +25 -0
- package/artifacts/schema/bridge-camera-state.schema.json +78 -0
- package/artifacts/schema/bridge-config-applied.schema.json +67 -0
- package/artifacts/schema/bridge-hello.schema.json +65 -0
- package/artifacts/schema/bridge-introspect.schema.json +114 -0
- package/artifacts/schema/bridge-job-lost.schema.json +22 -0
- package/artifacts/schema/bridge-job-update.schema.json +100 -0
- package/artifacts/schema/bridge-pong.schema.json +19 -0
- package/artifacts/schema/bridge-pressure.schema.json +292 -0
- package/artifacts/schema/bridge-state.schema.json +24 -0
- package/artifacts/schema/bridge-type-definitions.schema.json +169 -0
- package/artifacts/schema/busy-details.schema.json +115 -0
- package/artifacts/schema/camera-descriptor.schema.json +45 -0
- package/artifacts/schema/camera-list-response.schema.json +58 -0
- package/artifacts/schema/camera-source.schema.json +240 -0
- package/artifacts/schema/cancel-request.schema.json +20 -0
- package/artifacts/schema/client-accept-invitation-request.schema.json +35 -0
- package/artifacts/schema/client-auth.schema.json +18 -0
- package/artifacts/schema/client-cancel.schema.json +45 -0
- package/artifacts/schema/client-identity.schema.json +103 -0
- package/artifacts/schema/client-invoke.schema.json +45 -0
- package/artifacts/schema/client-login-request.schema.json +29 -0
- package/artifacts/schema/client-logout-request.schema.json +14 -0
- package/artifacts/schema/client-mcp-interaction-decision-response.schema.json +15 -0
- package/artifacts/schema/client-mcp-interaction.schema.json +59 -0
- package/artifacts/schema/client-oidc-callback-query.schema.json +28 -0
- package/artifacts/schema/client-oidc-exchange-request.schema.json +21 -0
- package/artifacts/schema/client-oidc-start-query.schema.json +36 -0
- package/artifacts/schema/client-password-reset-confirm-request.schema.json +22 -0
- package/artifacts/schema/client-password-reset-request.schema.json +24 -0
- package/artifacts/schema/client-provider-list-query.schema.json +17 -0
- package/artifacts/schema/client-provider-list-response.schema.json +34 -0
- package/artifacts/schema/client-publish.schema.json +40 -0
- package/artifacts/schema/client-refresh-request.schema.json +14 -0
- package/artifacts/schema/client-register-request.schema.json +44 -0
- package/artifacts/schema/client-resend-verification-request.schema.json +24 -0
- package/artifacts/schema/client-subscribe.schema.json +43 -0
- package/artifacts/schema/client-unsubscribe.schema.json +26 -0
- package/artifacts/schema/client-verify-email-request.schema.json +15 -0
- package/artifacts/schema/cloud-asset-request.schema.json +37 -0
- package/artifacts/schema/cloud-camera-start.schema.json +41 -0
- package/artifacts/schema/cloud-camera-stop.schema.json +26 -0
- package/artifacts/schema/cloud-cancel.schema.json +33 -0
- package/artifacts/schema/cloud-config.schema.json +1635 -0
- package/artifacts/schema/cloud-hello-error.schema.json +23 -0
- package/artifacts/schema/cloud-hello-ok.schema.json +19 -0
- package/artifacts/schema/cloud-introspect-request.schema.json +19 -0
- package/artifacts/schema/cloud-invoke.schema.json +40 -0
- package/artifacts/schema/cloud-ping.schema.json +19 -0
- package/artifacts/schema/cloud-publish.schema.json +28 -0
- package/artifacts/schema/cloud-type-request.schema.json +30 -0
- package/artifacts/schema/command-result.schema.json +175 -0
- package/artifacts/schema/config-draft-response.schema.json +1695 -0
- package/artifacts/schema/config-state.schema.json +124 -0
- package/artifacts/schema/config-version-response.schema.json +1641 -0
- package/artifacts/schema/config-versions-response.schema.json +33 -0
- package/artifacts/schema/create-app-invitation-request.schema.json +40 -0
- package/artifacts/schema/create-app-oidc-provider-request.schema.json +70 -0
- package/artifacts/schema/create-app-request.schema.json +30 -0
- package/artifacts/schema/create-app-user-request.schema.json +42 -0
- package/artifacts/schema/create-robot-request.schema.json +14 -0
- package/artifacts/schema/create-robot-response.schema.json +44 -0
- package/artifacts/schema/create-server-key-response.schema.json +65 -0
- package/artifacts/schema/create-team-invite-request.schema.json +43 -0
- package/artifacts/schema/datapoint-alert-row.schema.json +160 -0
- package/artifacts/schema/datapoint-config.schema.json +366 -0
- package/artifacts/schema/datapoint-display.schema.json +31 -0
- package/artifacts/schema/datapoint-event.schema.json +34 -0
- package/artifacts/schema/datapoint-frame.schema.json +28 -0
- package/artifacts/schema/datapoint-list-response.schema.json +61 -0
- package/artifacts/schema/datapoint-value.schema.json +28 -0
- package/artifacts/schema/developer-login-request.schema.json +19 -0
- package/artifacts/schema/dynamic-client-registration-request.schema.json +60 -0
- package/artifacts/schema/dynamic-client-registration-response.schema.json +68 -0
- package/artifacts/schema/error-frame.schema.json +23 -0
- package/artifacts/schema/exposure-counts.schema.json +39 -0
- package/artifacts/schema/exposure-list-response.schema.json +43 -0
- package/artifacts/schema/fetch-types-request.schema.json +19 -0
- package/artifacts/schema/fetch-types-response.schema.json +163 -0
- package/artifacts/schema/fleetless-user-list-response.schema.json +73 -0
- package/artifacts/schema/fleetless-user.schema.json +60 -0
- package/artifacts/schema/history-buckets-response.schema.json +79 -0
- package/artifacts/schema/history-query.schema.json +58 -0
- package/artifacts/schema/history-response.schema.json +150 -0
- package/artifacts/schema/history-samples-response.schema.json +68 -0
- package/artifacts/schema/introspection-response.schema.json +118 -0
- package/artifacts/schema/invoke-or-service-response.schema.json +141 -0
- package/artifacts/schema/invoke-request.schema.json +23 -0
- package/artifacts/schema/invoke-response.schema.json +125 -0
- package/artifacts/schema/job-actor.schema.json +34 -0
- package/artifacts/schema/job-event.schema.json +158 -0
- package/artifacts/schema/job-response.schema.json +123 -0
- package/artifacts/schema/job-run-list-response.schema.json +222 -0
- package/artifacts/schema/job-run-query.schema.json +95 -0
- package/artifacts/schema/job-run-summary-query.schema.json +23 -0
- package/artifacts/schema/job-run-summary.schema.json +33 -0
- package/artifacts/schema/job-run.schema.json +195 -0
- package/artifacts/schema/job-state.schema.json +11 -0
- package/artifacts/schema/job.schema.json +106 -0
- package/artifacts/schema/latency-bucket.schema.json +63 -0
- package/artifacts/schema/live-session-response.schema.json +41 -0
- package/artifacts/schema/mail-outcome.schema.json +20 -0
- package/artifacts/schema/mail-template-preview-request.schema.json +35 -0
- package/artifacts/schema/mail-template-preview-response.schema.json +31 -0
- package/artifacts/schema/mail-template-problem-details.schema.json +24 -0
- package/artifacts/schema/mcp-consent-grant-list-response.schema.json +52 -0
- package/artifacts/schema/mcp-consent-grant.schema.json +39 -0
- package/artifacts/schema/mcp-robot-datasheet.schema.json +115 -0
- package/artifacts/schema/mcp-role-preview-response.schema.json +134 -0
- package/artifacts/schema/missing-asset-query.schema.json +11 -0
- package/artifacts/schema/oauth-authorize-query.schema.json +47 -0
- package/artifacts/schema/oauth-redirect-response.schema.json +15 -0
- package/artifacts/schema/oauth-token-request.schema.json +47 -0
- package/artifacts/schema/oauth-token-response.schema.json +38 -0
- package/artifacts/schema/org-alerts-query.schema.json +15 -0
- package/artifacts/schema/org-event-dropped.schema.json +26 -0
- package/artifacts/schema/org-event-replay.schema.json +97 -0
- package/artifacts/schema/org-event-subscribe.schema.json +14 -0
- package/artifacts/schema/org-event-unsubscribe.schema.json +14 -0
- package/artifacts/schema/org-event.schema.json +75 -0
- package/artifacts/schema/org-firing-alerts-response.schema.json +178 -0
- package/artifacts/schema/org-health-query.schema.json +13 -0
- package/artifacts/schema/org-latency-query.schema.json +42 -0
- package/artifacts/schema/org-latency-response.schema.json +124 -0
- package/artifacts/schema/org-quota-usage-counts.schema.json +42 -0
- package/artifacts/schema/org-quota-usage.schema.json +102 -0
- package/artifacts/schema/org-quotas.schema.json +51 -0
- package/artifacts/schema/org-usage-query.schema.json +19 -0
- package/artifacts/schema/org-usage-response.schema.json +77 -0
- package/artifacts/schema/org.schema.json +30 -0
- package/artifacts/schema/parameter-invalid-details.schema.json +37 -0
- package/artifacts/schema/parameter-spec.schema.json +120 -0
- package/artifacts/schema/parameter-violation.schema.json +24 -0
- package/artifacts/schema/password-change-request.schema.json +21 -0
- package/artifacts/schema/password-reset-confirm.schema.json +19 -0
- package/artifacts/schema/password-reset-request.schema.json +14 -0
- package/artifacts/schema/patch-app-oidc-provider-request.schema.json +50 -0
- package/artifacts/schema/patch-app-user-request.schema.json +34 -0
- package/artifacts/schema/patch-auth-me-request.schema.json +22 -0
- package/artifacts/schema/patch-fleetless-user-request.schema.json +20 -0
- package/artifacts/schema/patch-org-request.schema.json +15 -0
- package/artifacts/schema/patch-org-response.schema.json +40 -0
- package/artifacts/schema/patch-robot-request.schema.json +15 -0
- package/artifacts/schema/patch-robot-response.schema.json +40 -0
- package/artifacts/schema/pending-team-invite-list-response.schema.json +52 -0
- package/artifacts/schema/pending-team-invite.schema.json +39 -0
- package/artifacts/schema/protected-resource-metadata.schema.json +41 -0
- package/artifacts/schema/publish-config-response.schema.json +21 -0
- package/artifacts/schema/publish-request.schema.json +17 -0
- package/artifacts/schema/publisher-config.schema.json +285 -0
- package/artifacts/schema/put-app-auth-config-request.schema.json +93 -0
- package/artifacts/schema/put-app-mail-template-request.schema.json +35 -0
- package/artifacts/schema/put-config-draft-request.schema.json +13 -0
- package/artifacts/schema/put-datapoint-display-request.schema.json +31 -0
- package/artifacts/schema/put-robot-details-request.schema.json +41 -0
- package/artifacts/schema/put-robot-details-response.schema.json +43 -0
- package/artifacts/schema/rate-limit-details.schema.json +15 -0
- package/artifacts/schema/refresh-request.schema.json +13 -0
- package/artifacts/schema/release-live-query.schema.json +13 -0
- package/artifacts/schema/rename-slug-request.schema.json +23 -0
- package/artifacts/schema/rename-slug-response.schema.json +24 -0
- package/artifacts/schema/resource-health-event.schema.json +72 -0
- package/artifacts/schema/resource-health-list-response.schema.json +80 -0
- package/artifacts/schema/resource-health-state.schema.json +68 -0
- package/artifacts/schema/robot-config-doc.schema.json +1616 -0
- package/artifacts/schema/robot-delete-query.schema.json +12 -0
- package/artifacts/schema/robot-deletion-summary.schema.json +63 -0
- package/artifacts/schema/robot-detail-response.schema.json +262 -0
- package/artifacts/schema/robot-details-doc.schema.json +33 -0
- package/artifacts/schema/robot-jobs-response.schema.json +119 -0
- package/artifacts/schema/robot-latency-series.schema.json +81 -0
- package/artifacts/schema/robot-list-item.schema.json +94 -0
- package/artifacts/schema/robot-list-response.schema.json +106 -0
- package/artifacts/schema/robot.schema.json +30 -0
- package/artifacts/schema/role-list-response.schema.json +48 -0
- package/artifacts/schema/role-permissions.schema.json +61 -0
- package/artifacts/schema/role.schema.json +35 -0
- package/artifacts/schema/ros-graph.schema.json +99 -0
- package/artifacts/schema/server-key-list-response.schema.json +64 -0
- package/artifacts/schema/server-key.schema.json +51 -0
- package/artifacts/schema/service-call-response.schema.json +13 -0
- package/artifacts/schema/service-config.schema.json +198 -0
- package/artifacts/schema/session-tokens.schema.json +28 -0
- package/artifacts/schema/sign-up-request.schema.json +26 -0
- package/artifacts/schema/sign-up-response.schema.json +127 -0
- package/artifacts/schema/slug-usage-response.schema.json +32 -0
- package/artifacts/schema/snapshot-header.schema.json +44 -0
- package/artifacts/schema/snapshot-meta-response.schema.json +85 -0
- package/artifacts/schema/subscribe-error.schema.json +31 -0
- package/artifacts/schema/team-invite.schema.json +57 -0
- package/artifacts/schema/tier-change-request.schema.json +17 -0
- package/artifacts/schema/type-definition.schema.json +144 -0
- package/artifacts/schema/types-response.schema.json +156 -0
- package/artifacts/schema/update-app-request.schema.json +32 -0
- package/artifacts/schema/urdf-completeness.schema.json +50 -0
- package/artifacts/schema/validation-issue.schema.json +43 -0
- package/artifacts/schema/waitlist-request.schema.json +15 -0
- package/artifacts/schema-outgoing/bridge-asset-progress.schema.json +102 -0
- package/artifacts/schema-outgoing/bridge-assets-available.schema.json +26 -0
- package/artifacts/schema-outgoing/bridge-camera-state.schema.json +80 -0
- package/artifacts/schema-outgoing/bridge-config-applied.schema.json +69 -0
- package/artifacts/schema-outgoing/bridge-hello.schema.json +68 -0
- package/artifacts/schema-outgoing/bridge-introspect.schema.json +119 -0
- package/artifacts/schema-outgoing/bridge-job-lost.schema.json +23 -0
- package/artifacts/schema-outgoing/bridge-job-update.schema.json +102 -0
- package/artifacts/schema-outgoing/bridge-pong.schema.json +20 -0
- package/artifacts/schema-outgoing/bridge-type-definitions.schema.json +174 -0
- package/artifacts/schema-outgoing/datapoint-frame.schema.json +29 -0
- package/artifacts/schema-outgoing/snapshot-header.schema.json +45 -0
- package/dist/alerts.d.ts +255 -0
- package/dist/alerts.js +193 -0
- package/dist/app-users.d.ts +606 -0
- package/dist/app-users.js +696 -0
- package/dist/apps.d.ts +175 -0
- package/dist/apps.js +267 -0
- package/dist/assets.d.ts +434 -0
- package/dist/assets.js +546 -0
- package/dist/audit.d.ts +129 -0
- package/dist/audit.js +238 -0
- package/dist/client-auth.d.ts +409 -0
- package/dist/client-auth.js +487 -0
- package/dist/common.d.ts +186 -0
- package/dist/common.js +199 -0
- package/dist/config-issues.d.ts +175 -0
- package/dist/config-issues.js +339 -0
- package/dist/config.d.ts +862 -0
- package/dist/config.js +1988 -0
- package/dist/errors.d.ts +52 -0
- package/dist/errors.js +786 -0
- package/dist/identity.d.ts +549 -0
- package/dist/identity.js +503 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/introspection.d.ts +99 -0
- package/dist/introspection.js +97 -0
- package/dist/jobs.d.ts +334 -0
- package/dist/jobs.js +345 -0
- package/dist/mcp.d.ts +239 -0
- package/dist/mcp.js +153 -0
- package/dist/oauth.d.ts +344 -0
- package/dist/oauth.js +488 -0
- package/dist/protocol.d.ts +781 -0
- package/dist/protocol.js +715 -0
- package/dist/realtime.d.ts +494 -0
- package/dist/realtime.js +512 -0
- package/dist/rest.d.ts +1989 -0
- package/dist/rest.js +1963 -0
- package/dist/routes.d.ts +94 -0
- package/dist/routes.js +2298 -0
- package/package.json +61 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,1988 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { applyError, slug, rosName, rosTypeName, fieldPath, SLUG_RULE, ROS_NAME_RULE, ROS_TYPE_NAME_RULE, FIELD_PATH_RULE, } from './common.js';
|
|
4
|
+
/**
|
|
5
|
+
* `alertSeverity` is identical for the stored row and this document-nested
|
|
6
|
+
* definition — `z.enum(['warning', 'error'])`, nothing more to say twice —
|
|
7
|
+
* so it is imported rather than redefined. Not re-exported from here: the
|
|
8
|
+
* barrel already carries it from `alerts.ts`, and wave 4 moves the
|
|
9
|
+
* definition itself into this file once `alerts.ts` retires.
|
|
10
|
+
*/
|
|
11
|
+
import { alertSeverity } from './alerts.js';
|
|
12
|
+
/**
|
|
13
|
+
* The exposure model (spec §4): what a developer configures per robot, how a
|
|
14
|
+
* configuration moves from draft to published, and how the cloud reports what
|
|
15
|
+
* it refuses.
|
|
16
|
+
*
|
|
17
|
+
* ## What this schema decides, and what it leaves to the cloud
|
|
18
|
+
*
|
|
19
|
+
* FL-002 names thirteen validation codes and this file implements some of
|
|
20
|
+
* them. The line was drawn four times while the format was written and never
|
|
21
|
+
* written down, so here it is.
|
|
22
|
+
*
|
|
23
|
+
* **Decided here** — everything a single entry, plus its own declared types,
|
|
24
|
+
* answers on its own: `unknown_key` (every object is this file's own
|
|
25
|
+
* `strictObject`, which is `z.strictObject` plus a missing key that names
|
|
26
|
+
* itself),
|
|
27
|
+
* `explicit_null`, `invalid_rate` (`rateThrottleHz`),
|
|
28
|
+
* `requires_single_field`, `invalid_condition`,
|
|
29
|
+
* `constraint_not_allowed_for_type`, `value_type_mismatch` and
|
|
30
|
+
* `failsafe_has_parameters`. The name grammar comes with the key, and
|
|
31
|
+
* `duplicate_slug` and `duplicate_parameter` come with the mapping — a
|
|
32
|
+
* repeated key is a YAML syntax error before any schema sees it.
|
|
33
|
+
*
|
|
34
|
+
* **Left to the cloud**, for one of two reasons:
|
|
35
|
+
*
|
|
36
|
+
* - *It needs introspection.* `unknown_topic`, `unknown_field_path`,
|
|
37
|
+
* `type_mismatch` and `requires_numeric_field` are all questions about the
|
|
38
|
+
* robot's own message definitions. This schema has no robot.
|
|
39
|
+
* - *It spans sections, or documents.* `reserved_slug` and cross-section
|
|
40
|
+
* `duplicate_slug` need the whole document; `undeclared_parameter`,
|
|
41
|
+
* `unused_parameter`, `unknown_message` and `nested_message_reference` need
|
|
42
|
+
* the index of declared names that `messages:` and each entry's
|
|
43
|
+
* `parameters:` build together.
|
|
44
|
+
*
|
|
45
|
+
* `nested_message_reference` is the one worth naming explicitly, because it
|
|
46
|
+
* looks decidable here and is: a `messages:` entry whose whole body is
|
|
47
|
+
* `'${name}'` is a nested reference, full stop. It is the cloud's anyway, so
|
|
48
|
+
* that all four name-resolution codes are answered in one place against one
|
|
49
|
+
* index. Splitting them would put one rule here and its three siblings there
|
|
50
|
+
* — the shape this file has twice had to undo.
|
|
51
|
+
*
|
|
52
|
+
* **Every refusal that answers one of the spec's codes carries
|
|
53
|
+
* `params: { code }`** with that code, which zod passes through `safeParse`
|
|
54
|
+
* untouched. The cloud maps an issue to a code and its repair by reading that
|
|
55
|
+
* field, never by matching the message prose — a join nobody notices
|
|
56
|
+
* breaking.
|
|
57
|
+
*
|
|
58
|
+
* Read the sentence narrowly, because a wider reading is false and was
|
|
59
|
+
* written here once. Plenty of refusals in this file carry no `params.code`,
|
|
60
|
+
* and correctly: the section caps (`parameterMap`'s fifty, `messageMap`'s two
|
|
61
|
+
* hundred), the camera device-path rules, and every refusal zod raises on its
|
|
62
|
+
* own — `unrecognized_keys` behind `unknown_key`, `too_big` behind
|
|
63
|
+
* `invalid_rate`. Those are not spec codes wearing a different hat; the cloud
|
|
64
|
+
* reaches them through zod's own issue codes. The one *spec* code with no
|
|
65
|
+
* `params` is a reversed pair of bounds — `min_value`/`max_value` on a
|
|
66
|
+
* parameter, `y_min`/`y_max` on a chart: `invalid_range` was deleted with
|
|
67
|
+
* `expected_range`, and no code replaced it.
|
|
68
|
+
*
|
|
69
|
+
* `robotConfigDoc` carries all six sections — messages, datapoints, actions,
|
|
70
|
+
* services, publishers and cameras — plus, since FL-002, the alerts, the
|
|
71
|
+
* chart bounds and the camera credentials that used to live outside it.
|
|
72
|
+
* Everything configurable about a robot is in this document, and there is one
|
|
73
|
+
* door to it. FL-002 rewrote the slug grammar (underscores, not dashes) and
|
|
74
|
+
* keyed every section by name; draft/publish and versioning are unchanged.
|
|
75
|
+
*/
|
|
76
|
+
/**
|
|
77
|
+
* What every `pattern` in this document means, said in words.
|
|
78
|
+
*
|
|
79
|
+
* A developer whose topic name was wrong used to be shown the regular
|
|
80
|
+
* expression that refused it. These are the sentences that replace it — one
|
|
81
|
+
* per grammar rather than one per field, because the message explains why the
|
|
82
|
+
* *pattern* said no, and the same pattern says no for the same reason wherever
|
|
83
|
+
* it appears.
|
|
84
|
+
*
|
|
85
|
+
* **Four of the seven are not here.** `SLUG_RULE`, `ROS_NAME_RULE`,
|
|
86
|
+
* `ROS_TYPE_NAME_RULE` and `FIELD_PATH_RULE` belong to patterns `common.ts`
|
|
87
|
+
* declares, and each one lives beside its pattern so that the two cannot move
|
|
88
|
+
* apart; that file's header says why. The three below exist only inside a
|
|
89
|
+
* configuration document, so they are here, beside theirs, on the same rule.
|
|
90
|
+
*
|
|
91
|
+
* **Where they are read, and when.** Each is used twice: as the message **zod
|
|
92
|
+
* itself produces**, and as `patternErrorMessage` in the exported JSON Schema,
|
|
93
|
+
* which is a published artifact other tools validate against and which a person
|
|
94
|
+
* reads. One constant with two readers, never two strings that happen to agree
|
|
95
|
+
* — `config-zod-messages.test.ts` asserts the two readings are the same string
|
|
96
|
+
* at all 24 pattern positions the document has, because under D3 nothing
|
|
97
|
+
* consumes `patternErrorMessage` at runtime and an unwatched second spelling of
|
|
98
|
+
* a live rule drifts word for word, forever and invisibly.
|
|
99
|
+
*
|
|
100
|
+
* In the console `patternErrorMessage` is also the live pattern diagnostic
|
|
101
|
+
* **until wave 3 lands**: `useMonacoYaml.ts` still passes `validate: true`, so
|
|
102
|
+
* between task 7's artifacts and D3 these sentences are what monaco-yaml shows.
|
|
103
|
+
* D3 then turns that validation off, and from there the message a developer
|
|
104
|
+
* sees comes from this schema's own parser and from the cloud — the same
|
|
105
|
+
* sentence, which is the point of there being one.
|
|
106
|
+
*
|
|
107
|
+
* The tense matters because the two states look identical from inside this
|
|
108
|
+
* file. Whoever reads it after wave 3 should find a claim that was true when
|
|
109
|
+
* written and stayed true, not one that quietly became false.
|
|
110
|
+
*
|
|
111
|
+
* Each one states the rule in words and gives one example, and none of them
|
|
112
|
+
* quotes its own regex — `config-messages.test.ts` asserts that over every
|
|
113
|
+
* pattern in the exported document, not over the three below.
|
|
114
|
+
*/
|
|
115
|
+
const RTSP_URL_RULE = 'The URL has to begin with `rtsp://` or `rtsps://` — `rtsp://cam-1.plant.local/stream1`. No other scheme is accepted: the bridge opens this with a library that would equally honour `file:`.';
|
|
116
|
+
const MJPEG_URL_RULE = 'The URL has to begin with `http://` or `https://` — `http://cam-1.plant.local/video.mjpg`. No other scheme is accepted: the bridge opens this with a library that would equally serve `file:`.';
|
|
117
|
+
const DEVICE_PATH_RULE = 'A capture device is a path under `/dev/`, and the character straight after it is a letter or a digit — `/dev/video0`, or a stable `/dev/v4l/by-id/...` symlink. Nothing outside `/dev/` is accepted: the string reaches OpenCV, which would as happily open an ordinary file.';
|
|
118
|
+
/**
|
|
119
|
+
* The key of every section, every parameter map and the alert map: a slug,
|
|
120
|
+
* carrying the grammar's sentence.
|
|
121
|
+
*
|
|
122
|
+
* **`slug` itself stays plain in `common.ts`, and the reason is blast radius.**
|
|
123
|
+
* Not metadata loss: `.meta()` on a clone *merges* with the parent's entry per
|
|
124
|
+
* key and resolves it lazily, measured against zod 4.4.3 and written up at
|
|
125
|
+
* `messageBody`'s own `.meta()` below — a later `description` on a use of
|
|
126
|
+
* `slug` would keep the sentence, not drop it.
|
|
127
|
+
*
|
|
128
|
+
* What that reach would cost was measured instead, by adding the one `.meta()`
|
|
129
|
+
* line to `slug` in a copy of `src/` and re-exporting every artifact under each
|
|
130
|
+
* schema's own `io`: **42 of the 159 published schema artifacts** would carry
|
|
131
|
+
* it, `bridge-hello`, `datapoint-frame`, `snapshot-header` and
|
|
132
|
+
* `bridge-camera-state` among them — protocol frames the bridge **vendors**
|
|
133
|
+
* under `bridge/test/contracts/schema/`, so rewording one sentence would become
|
|
134
|
+
* a re-vendor plus a `SOURCE.md` edit in another repo. As landed the same
|
|
135
|
+
* search finds **7**, all config-derived.
|
|
136
|
+
*
|
|
137
|
+
* Whether `vscode-json-languageservice` honours `patternErrorMessage` on a
|
|
138
|
+
* `propertyNames` schema at all is **not measured** — §1.3 measured a value
|
|
139
|
+
* position, not a key one. It ships for the same reason as the rest: the
|
|
140
|
+
* artifact is read by tools and by people.
|
|
141
|
+
*/
|
|
142
|
+
const mapKey = slug.meta({ patternErrorMessage: SLUG_RULE });
|
|
143
|
+
/**
|
|
144
|
+
* One field, carrying the sentence it says when it is absent.
|
|
145
|
+
*
|
|
146
|
+
* **Three shapes of "this key is not here", and the first version of this
|
|
147
|
+
* helper caught one of them.** A walk over every required key of a fully
|
|
148
|
+
* populated document — `config-zod-messages.test.ts`, which is the guard that
|
|
149
|
+
* found it — says the format has 52 required-key positions and that 14 were
|
|
150
|
+
* still answering in zod's words:
|
|
151
|
+
*
|
|
152
|
+
* - `invalid_type`, the ordinary case: a string, a number, an object.
|
|
153
|
+
* - `invalid_value` from a `z.literal` or a `z.enum`. A missing `fleetless:`
|
|
154
|
+
* said `Invalid input: expected 1`; a parameter without a `type` recited all
|
|
155
|
+
* fifteen ROS primitives.
|
|
156
|
+
* - `invalid_union`. `alertCondition.fire_at` said `Invalid input` and nothing
|
|
157
|
+
* else, and a camera source without `kind` said `Invalid discriminator value`
|
|
158
|
+
* — where the input is not `undefined` at all but the object that lacks the
|
|
159
|
+
* key, which is why that branch is tested separately.
|
|
160
|
+
*
|
|
161
|
+
* So the rule is the fact rather than the code: **a required field whose input
|
|
162
|
+
* is absent is a missing key, whatever zod calls the refusal.** For a value
|
|
163
|
+
* present as an explicit `undefined` the sentence reads as "missing", which is
|
|
164
|
+
* what the format means by it: omission is this format's only spelling of "not
|
|
165
|
+
* set".
|
|
166
|
+
*
|
|
167
|
+
* **`z.unknown()` needs a wrapper before it can be given a sentence at all.**
|
|
168
|
+
* It accepts `undefined`, so zod marks the key required and raises its own
|
|
169
|
+
* `expected nonoptional, received undefined` — an issue it attributes to
|
|
170
|
+
* neither the field nor the object, so no error map of ours is consulted
|
|
171
|
+
* (measured). `z.nonoptional` puts a schema there that can carry one. The JSON
|
|
172
|
+
* Schema and the inferred type are byte-identical either way (measured, zod
|
|
173
|
+
* 4.4.3), and a field that is genuinely optional is `.optional()` and is
|
|
174
|
+
* skipped here — but it is **not** behaviourally free, and that is the one
|
|
175
|
+
* place this task changed what the format accepts: a required key *present*
|
|
176
|
+
* holding `undefined` is now refused where zod's internal check accepted it.
|
|
177
|
+
*
|
|
178
|
+
* That was ruled in deliberately rather than noticed later, because no route
|
|
179
|
+
* into this format can carry such a key — JSON drops it, YAML's `message:`
|
|
180
|
+
* yields `null`, and jsonb cannot represent it — and because the alternative
|
|
181
|
+
* leaves `expected nonoptional, received undefined` on `message`, a field
|
|
182
|
+
* developers write by hand. The three measurements, and the condition that
|
|
183
|
+
* would make them false, are written where they are asserted:
|
|
184
|
+
* `config-zod-messages.test.ts`, *"and no route into the format can carry
|
|
185
|
+
* one"*. Read that before changing this line.
|
|
186
|
+
*
|
|
187
|
+
* `clone` is the only way to add an `error` to a schema that is already built,
|
|
188
|
+
* and **it drops the schema's registry entry** — its `description`, its
|
|
189
|
+
* `examples`, every annotation this wave added, all of which live in
|
|
190
|
+
* `z.globalRegistry` keyed by the schema instance rather than in its
|
|
191
|
+
* definition. So the entry is read back and put on the clone. Measured on zod
|
|
192
|
+
* 4.4.3: `z.globalRegistry.get` resolves the whole `.meta()` parent chain into
|
|
193
|
+
* one object, so what is copied is what the export would have produced, and a
|
|
194
|
+
* later `.meta()` on the result merges with it as it did before. A wrapper that
|
|
195
|
+
* silently emptied every hover in the format would be the worst available way
|
|
196
|
+
* to improve one message.
|
|
197
|
+
*
|
|
198
|
+
* Two residuals, neither reachable in this file today and both worth knowing
|
|
199
|
+
* before it grows: `{ ...def }` is a shallow spread and `def.shape` is a
|
|
200
|
+
* **getter**, so the spread resolves every nested shape at module-eval time —
|
|
201
|
+
* a `z.lazy` or a forward reference added later would be resolved here before
|
|
202
|
+
* its cycle closed, and the JSON Schema export could still look identical. And
|
|
203
|
+
* the copied registry entry is the resolved merge with no parent link, so a
|
|
204
|
+
* `.meta()` called on an original field *after* `strictObject` consumed it
|
|
205
|
+
* would not reach the copy inside the document. Neither is reachable here, and
|
|
206
|
+
* not by inspection: this module evaluates top to bottom, so a forward
|
|
207
|
+
* reference in any shape would be a `ReferenceError` at import rather than a
|
|
208
|
+
* subtle export — the module loading at all is the measurement. The one
|
|
209
|
+
* `z.lazy` in `contracts` is `introspection.ts`'s `typeField`, which no shape
|
|
210
|
+
* in this file holds. And every `.meta()` here is applied before the shape is
|
|
211
|
+
* handed over, which is what the file reads like and what the export
|
|
212
|
+
* comparison would show if it were not.
|
|
213
|
+
*
|
|
214
|
+
* The inherited `error` is kept and deferred to, so this composes with a
|
|
215
|
+
* field that already carries one rather than replacing it.
|
|
216
|
+
*/
|
|
217
|
+
const saysItIsMissing = (field) => {
|
|
218
|
+
const meta = z.globalRegistry.get(field);
|
|
219
|
+
const def = { ...field._zod.def };
|
|
220
|
+
const inherited = def.error;
|
|
221
|
+
const carrier = def.type === 'optional' || !field.safeParse(undefined).success
|
|
222
|
+
? field
|
|
223
|
+
: z.nonoptional(field);
|
|
224
|
+
const carrierDef = carrier === field
|
|
225
|
+
? def
|
|
226
|
+
: { ...carrier._zod.def };
|
|
227
|
+
carrierDef.error = (issue) => {
|
|
228
|
+
const key = issue.path?.[issue.path.length - 1];
|
|
229
|
+
if (typeof key === 'string' && absent(issue, key))
|
|
230
|
+
return `Missing required key \`${key}\`.`;
|
|
231
|
+
return typeof inherited === 'function' ? inherited(issue) : inherited;
|
|
232
|
+
};
|
|
233
|
+
const cloned = carrier.clone(carrierDef);
|
|
234
|
+
if (meta !== undefined)
|
|
235
|
+
z.globalRegistry.add(cloned, meta);
|
|
236
|
+
return cloned;
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Whether an issue is one field's way of saying the key is not there.
|
|
240
|
+
*
|
|
241
|
+
* The second arm is the discriminated union: zod hands its error map the whole
|
|
242
|
+
* object and points the path at the discriminator, so `input` is not
|
|
243
|
+
* `undefined` and the first arm cannot see it. `Object.hasOwn` rather than
|
|
244
|
+
* `in`, on this project's own rule — a document's keys are chosen by a
|
|
245
|
+
* developer, and `constructor` satisfies the slug grammar.
|
|
246
|
+
*/
|
|
247
|
+
const absent = (issue, key) => issue.input === undefined
|
|
248
|
+
|| (issue.code === 'invalid_union'
|
|
249
|
+
&& typeof issue.input === 'object'
|
|
250
|
+
&& issue.input !== null
|
|
251
|
+
&& !Object.hasOwn(issue.input, key));
|
|
252
|
+
/** Each field of a shape, carrying the sentence it says when it is missing. */
|
|
253
|
+
const namesItsAbsence = (shape) => Object.fromEntries(Object.entries(shape).map(([key, field]) => [key, saysItIsMissing(field)]));
|
|
254
|
+
/**
|
|
255
|
+
* Every object in this document is strict, and every required key of it says
|
|
256
|
+
* its own name when it is absent.
|
|
257
|
+
*
|
|
258
|
+
* **This is not `z.strictObject`** — it is this file's, wrapping it. The
|
|
259
|
+
* difference is the second half: `Invalid input: expected object, received
|
|
260
|
+
* undefined` was the whole of what a developer was told when a camera had no
|
|
261
|
+
* `source:` (design §1.5), naming neither the key nor the fact that it was
|
|
262
|
+
* required. monaco-yaml said `Missing property "source".` for the same
|
|
263
|
+
* document, and was right to.
|
|
264
|
+
*
|
|
265
|
+
* It has to be done a field at a time. zod attributes a missing key to the
|
|
266
|
+
* **field's own** schema — an `invalid_type` whose input is `undefined` — and
|
|
267
|
+
* an `error` on the containing object is never consulted for it; measured on
|
|
268
|
+
* zod 4.4.3, an error map on the object saw no such issue at all. So the
|
|
269
|
+
* sentence is attached to every field of every shape, here, in one place,
|
|
270
|
+
* rather than at the eighteen objects and hundred-odd fields it would
|
|
271
|
+
* otherwise have to be remembered at.
|
|
272
|
+
*
|
|
273
|
+
* **How "every" is enforced, because the first version of this comment said
|
|
274
|
+
* "every" and was wrong.** Six of the eighteen objects were written
|
|
275
|
+
* `z\n .strictObject({`, so `z.strictObject` never appeared on one line and a
|
|
276
|
+
* `grep` for it returned only prose. Twelve conversions read as eighteen, and
|
|
277
|
+
* eleven required keys — `datapoints.<slug>.topic` and `.type` among them,
|
|
278
|
+
* which is the commonest entry in the whole format — went on reciting the
|
|
279
|
+
* sentence §1.5 calls unusable. The claim was in the source, which is what the
|
|
280
|
+
* next person reads.
|
|
281
|
+
*
|
|
282
|
+
* What makes it true now is not this paragraph. It is
|
|
283
|
+
* `config-zod-messages.test.ts`'s *"a required key that is absent names
|
|
284
|
+
* itself"*: a walk of the exported schema's `required` arrays against a
|
|
285
|
+
* fully-populated document, `oneOf` branches resolved by their discriminator,
|
|
286
|
+
* deleting one key at a time and asserting the message names it. It reaches 52
|
|
287
|
+
* positions across 19 objects, and the count comes out of the walk rather than
|
|
288
|
+
* off a list — a required key added to the format later is swept the day it
|
|
289
|
+
* exists, and an object that skips this helper is red before it is merged.
|
|
290
|
+
*/
|
|
291
|
+
const strictObject = (shape) => z.strictObject(namesItsAbsence(shape));
|
|
292
|
+
/**
|
|
293
|
+
* A map keyed by slugs, whose refused key says **which** key and **why**.
|
|
294
|
+
*
|
|
295
|
+
* `Invalid key in record` was the worst sentence in the format and it lands on
|
|
296
|
+
* the commonest beginner mistake — a section keyed `Battery` rather than
|
|
297
|
+
* `battery`. It named neither the key nor the grammar, and the grammar was
|
|
298
|
+
* sitting one level down, on the key schema's own issue, where nothing that
|
|
299
|
+
* renders a `safeParse` result ever looks: the cloud's 422 and the console both
|
|
300
|
+
* read the top-level `issues[].message` and nothing below it.
|
|
301
|
+
*
|
|
302
|
+
* So the sentence is composed from exactly that: the key, then the messages of
|
|
303
|
+
* the issues the key schema itself raised. A key too short says so; a key that
|
|
304
|
+
* breaks the grammar states the grammar, `SLUG_RULE` verbatim. Composing rather
|
|
305
|
+
* than restating is what keeps this from becoming a second wording of the rule
|
|
306
|
+
* the moment the rule is reworded.
|
|
307
|
+
*
|
|
308
|
+
* The path already carries the key (`datapoints.Battery`) and always did — the
|
|
309
|
+
* fix is the sentence, not the path — but a message that reads correctly on its
|
|
310
|
+
* own is what a diagnostic list, a 422 body and a hover all need.
|
|
311
|
+
*/
|
|
312
|
+
const slugKeyed = (entry) => z.record(mapKey, entry, {
|
|
313
|
+
error: (issue) => {
|
|
314
|
+
if (issue.code !== 'invalid_key')
|
|
315
|
+
return undefined;
|
|
316
|
+
const why = issue.issues.map((inner) => inner.message).filter(Boolean).join(' ');
|
|
317
|
+
return why.length === 0 ? undefined : `\`${String(issue.input)}\` is not a valid name. ${why}`;
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
/**
|
|
321
|
+
* `enumDescriptions` built from a table keyed by the **value**, never written
|
|
322
|
+
* out as a positional array.
|
|
323
|
+
*
|
|
324
|
+
* The consuming key is positional — `enumDescriptions[i]` documents `enum[i]` —
|
|
325
|
+
* and that is the whole hazard. Fifteen sentences hand-aligned against
|
|
326
|
+
* `parameterType`'s declaration order would misalign in silence the day
|
|
327
|
+
* somebody regroups that list, which is a grouping rather than an order the
|
|
328
|
+
* format needs. It would misalign only in the published artifact, where
|
|
329
|
+
* nothing else looks.
|
|
330
|
+
*
|
|
331
|
+
* So the alignment is made unrepresentable rather than tested: the table is
|
|
332
|
+
* keyed by value, `Record<V, string>` makes a missing value a **typecheck**
|
|
333
|
+
* failure the day one is added, and the order comes from the enum's own
|
|
334
|
+
* `.options`. `config-messages.test.ts` still checks arity, non-emptiness and
|
|
335
|
+
* that the sentences differ from one another — what it cannot check, and says
|
|
336
|
+
* so, is whether a sentence is the *right* one for its value.
|
|
337
|
+
*/
|
|
338
|
+
const describeValues = (values, table) => values.map((value) => table[value]);
|
|
339
|
+
/**
|
|
340
|
+
* One of the format's own parameter holes, `${name}`, written so that it
|
|
341
|
+
* survives a `defaultSnippets` insert. **The backslash is load-bearing and is
|
|
342
|
+
* not a typo to tidy away.**
|
|
343
|
+
*
|
|
344
|
+
* The two syntaxes collide. `defaultSnippets` bodies are inserted as LSP
|
|
345
|
+
* snippets, where `${1:front}` is a tab stop and `${speed}` is a *variable* —
|
|
346
|
+
* and an unknown variable is not left alone. Measured against
|
|
347
|
+
* monaco-editor 0.52.2's own `SnippetParser`, which is what the console runs:
|
|
348
|
+
*
|
|
349
|
+
* | body holds | the editor inserts |
|
|
350
|
+
* |---|---|
|
|
351
|
+
* | `x: ${speed}` | `x: ` — the hole is **deleted**, silently |
|
|
352
|
+
* | `x: \${speed}` | `x: ${speed}` |
|
|
353
|
+
*
|
|
354
|
+
* yaml-language-server emits body strings verbatim (`stringifyObject`'s
|
|
355
|
+
* replacer only strips a leading `^` and quotes `true`/`false`), so nothing
|
|
356
|
+
* between here and the snippet engine escapes it for us. A body that writes a
|
|
357
|
+
* parameter unescaped therefore offers a developer a publisher whose message
|
|
358
|
+
* has lost the very value a caller was meant to fill, which parses and is
|
|
359
|
+
* wrong — the worst available outcome for a hint the developer trusts.
|
|
360
|
+
*/
|
|
361
|
+
const param = (name) => `\\\${${name}}`;
|
|
362
|
+
/**
|
|
363
|
+
* The same skeleton, offered one level out — at the section rather than at the
|
|
364
|
+
* entry.
|
|
365
|
+
*
|
|
366
|
+
* A section body is its entry body under one slug key: `datapoints:` offers
|
|
367
|
+
* `{ battery: … }`, and `battery: ▮` offers the `…`. Both positions are real
|
|
368
|
+
* and both were silent, but they are **one skeleton**, so each is authored once
|
|
369
|
+
* as a `Snippet` constant and wrapped here for the section — never copied.
|
|
370
|
+
* Two copies of one skeleton is the drift this wave caught three times in three
|
|
371
|
+
* reviews: a body inventing a value its sibling had already answered, under a
|
|
372
|
+
* label that still agreed. `config-snippets.test.ts` deep-compares the two
|
|
373
|
+
* positions rather than trusting this.
|
|
374
|
+
*
|
|
375
|
+
* **The slug key belongs to the wrapper, not to the skeleton**, because it
|
|
376
|
+
* differs per snippet — `battery_voltage` for a plain datapoint, `battery` for
|
|
377
|
+
* the numeric one. It therefore takes tab stop `${1}`, and an entry body's own
|
|
378
|
+
* stops are numbered from `${2}` throughout. At the entry position that leaves
|
|
379
|
+
* no `${1}` at all, which costs nothing — measured against
|
|
380
|
+
* monaco-editor 0.52.2's own `SnippetParser`, the version the console runs: it
|
|
381
|
+
* sorts placeholders by index and requires neither that they start at 1 nor
|
|
382
|
+
* that they be contiguous, so a body of `a: ${2:x}` visits `2` first, and one
|
|
383
|
+
* of `a: ${2:x}` / `b: ${5:y}` visits `2` then `5`.
|
|
384
|
+
*/
|
|
385
|
+
const underSlug = (slugKey, snippet) => ({ ...snippet, body: { [slugKey]: snippet.body } });
|
|
386
|
+
/**
|
|
387
|
+
* What an exposed service *is*, in the developer's own words (§17).
|
|
388
|
+
*
|
|
389
|
+
* This is what `robot_describe` carries verbatim, so it is read by a model
|
|
390
|
+
* that has never seen this robot and cannot ask a follow-up question.
|
|
391
|
+
* `unit` and `range` already say what a number *is*; this says what it
|
|
392
|
+
* *means*.
|
|
393
|
+
*
|
|
394
|
+
* **It lives on the configuration rather than on the app, and that was a
|
|
395
|
+
* decision with a cost.** §17's own wording put the semantic descriptions in
|
|
396
|
+
* the MCP app; André moved them here on 2026-08-18 so that a description is
|
|
397
|
+
* written once per service and true for every app that reaches the robot,
|
|
398
|
+
* beside the other metadata. What is given up is real and should not be
|
|
399
|
+
* rediscovered as a bug: **two apps can no longer describe one service
|
|
400
|
+
* differently for two audiences.** §17 was reworded in the same wave rather
|
|
401
|
+
* than left contradicting this field.
|
|
402
|
+
*
|
|
403
|
+
* **`.optional()` and not `.nullable().default(null)`, deliberately.** The
|
|
404
|
+
* established shape in this file is a default — and every use of it has
|
|
405
|
+
* added an instance to a known contradiction: `.default()` publishes the
|
|
406
|
+
* field as **required** in the generated JSON Schema, because after parsing
|
|
407
|
+
* it is always present. That is recorded four times over in
|
|
408
|
+
* `scripts/export-schemas.ts`, whose fix (`io: 'input'`, applied per schema)
|
|
409
|
+
* is a judgement call across roughly sixty schemas plus a re-vendor and a
|
|
410
|
+
* re-pin in four repos. W7c's playbook said task 0 would do it; reading the
|
|
411
|
+
* measured blast radius — 90 artifacts, 436 deletions for the blanket
|
|
412
|
+
* version — said otherwise, at the start of a wave with five people blocked
|
|
413
|
+
* on this pin. So the field simply does not create a fifth instance:
|
|
414
|
+
* optional is optional in both modes, and *absent* is the single spelling of
|
|
415
|
+
* "not described". `.min(1)` keeps the empty string from becoming a second.
|
|
416
|
+
*/
|
|
417
|
+
export const serviceDescription = z.string().min(1).max(2000).optional();
|
|
418
|
+
/**
|
|
419
|
+
* One parameter's prose, for the same reader as `serviceDescription` and
|
|
420
|
+
* under the same rules. Shorter, because it describes one field of one call
|
|
421
|
+
* rather than the call itself.
|
|
422
|
+
*/
|
|
423
|
+
export const parameterDescription = z.string().min(1).max(500).optional();
|
|
424
|
+
/** The ROS 2 primitive field types, spelled as ROS 2 spells them. */
|
|
425
|
+
export const parameterType = z.enum([
|
|
426
|
+
'bool', 'byte', 'char',
|
|
427
|
+
'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'int64', 'uint64',
|
|
428
|
+
'float32', 'float64',
|
|
429
|
+
'string', 'wstring',
|
|
430
|
+
]);
|
|
431
|
+
const INTEGER_TYPES = new Set(['byte', 'char', 'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'int64', 'uint64']);
|
|
432
|
+
const FLOAT_TYPES = new Set(['float32', 'float64']);
|
|
433
|
+
const STRING_TYPES = new Set(['string', 'wstring']);
|
|
434
|
+
/**
|
|
435
|
+
* One hole a caller fills, authored once for the two positions it is offered
|
|
436
|
+
* from: `parameters:` (under `underSlug`) and the value of one entry below it.
|
|
437
|
+
*
|
|
438
|
+
* `type` is a placeholder default rather than a choice, unlike the camera
|
|
439
|
+
* source's `type`, and for two reasons that are **not** "the editor offers the
|
|
440
|
+
* fifteen here anyway". It does not: after the insert this position holds
|
|
441
|
+
* `float64` as a selected tab stop, and Monaco does not open the suggest widget
|
|
442
|
+
* over one — nor as the developer types over the selection, since an unquoted
|
|
443
|
+
* YAML scalar tokenizes as `string` and the editor's default
|
|
444
|
+
* `quickSuggestions.strings` is false. The fifteen are an explicit Ctrl+Space
|
|
445
|
+
* away.
|
|
446
|
+
*
|
|
447
|
+
* The reasons that do hold: fifteen options is a list that would have to be
|
|
448
|
+
* maintained beside the enum, and the enum is the format's own answer — a
|
|
449
|
+
* snippet must not fork it, and any shorter list is a subset presented as the
|
|
450
|
+
* set. And a wrong pick here **is** reported: `type_mismatch` checks the
|
|
451
|
+
* declared type against the type at the template position, which is the half of
|
|
452
|
+
* the rule that settles it. That is the difference from the camera `type`,
|
|
453
|
+
* where a wrong pick is accepted by every layer and the bridge then delivers no
|
|
454
|
+
* frames with nothing objecting.
|
|
455
|
+
*
|
|
456
|
+
* The bounds are the point of the block — the field descriptions call them
|
|
457
|
+
* where a speed limit actually holds — so they are in the skeleton, at
|
|
458
|
+
* `min_value`/`max_value`'s own `examples`. They are coupled to `type`: tabbing
|
|
459
|
+
* `float64` to `string` makes them `constraint_not_allowed_for_type`, one line
|
|
460
|
+
* below the pick, in a message that names the value just chosen. That is
|
|
461
|
+
* deliberate, where omitting the bounds would leave the format's one
|
|
462
|
+
* enforcement point out of the hint that introduces it. **When the developer
|
|
463
|
+
* meets it is not today**: this is a `superRefine`, so it is not in the JSON
|
|
464
|
+
* Schema export and monaco-yaml cannot see it — until the console validates
|
|
465
|
+
* against `robotConfigDoc` itself, the refusal arrives on save rather than
|
|
466
|
+
* under the cursor.
|
|
467
|
+
*
|
|
468
|
+
* No `default`, so the parameter is required: `default` is the one field here
|
|
469
|
+
* with no `examples`, and "has no default" is the format's spelling of
|
|
470
|
+
* required, which is the honest thing for a skeleton to start from. That every
|
|
471
|
+
* declared parameter must also appear in the `message` is a question about the
|
|
472
|
+
* whole entry and is the cloud's, not this schema's.
|
|
473
|
+
*/
|
|
474
|
+
const PARAMETER_SNIPPET = {
|
|
475
|
+
label: 'a parameter, with its bounds',
|
|
476
|
+
description: 'One hole a caller fills: what type it is, what values it may take, and what it means. Without a `default` it is required, and the bounds are enforced in the cloud before anything reaches the robot.',
|
|
477
|
+
body: {
|
|
478
|
+
type: '${2:float64}',
|
|
479
|
+
min_value: -0.5,
|
|
480
|
+
max_value: 0.5,
|
|
481
|
+
description: '${3:What a caller is choosing when they set this.}',
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* One parameter a caller may fill in a message template.
|
|
486
|
+
*
|
|
487
|
+
* `type` is required and **not derived from the template position**, even
|
|
488
|
+
* though introspection usually knows it. The reason is the robot that has
|
|
489
|
+
* never connected: there is nothing to derive there, and that is exactly
|
|
490
|
+
* where the editor has to help most.
|
|
491
|
+
*
|
|
492
|
+
* Which constraints exist depends on the type, and a constraint on the wrong
|
|
493
|
+
* type is refused rather than silently inert. Floats deliberately have no
|
|
494
|
+
* `enum`: equality on floating point is unreliable, so an enumerated float
|
|
495
|
+
* list is a trap that only shows up in operation.
|
|
496
|
+
*
|
|
497
|
+
* **A `default` and every `enum` entry must match `type`**, and that is
|
|
498
|
+
* decided here rather than in the cloud. Its sibling
|
|
499
|
+
* `constraint_not_allowed_for_type` was always here, and leaving one of a
|
|
500
|
+
* pair in zod and the other in the cloud is two policies for one decision.
|
|
501
|
+
* It needs nothing this schema does not have: a value and a declared type.
|
|
502
|
+
* `type_mismatch` — the declared type against the type at the template
|
|
503
|
+
* position — is the one that needs introspection, and it is the cloud's.
|
|
504
|
+
*
|
|
505
|
+
* There is no `required` field. A placeholder cannot be left unfilled, so
|
|
506
|
+
* "required" is exactly "has no `default`" — a second spelling of one fact
|
|
507
|
+
* is the defect this file has spent two waves removing.
|
|
508
|
+
*/
|
|
509
|
+
export const parameterSpec = strictObject({
|
|
510
|
+
type: parameterType.meta({
|
|
511
|
+
description: 'The ROS 2 primitive a value of this parameter must be, spelled the way ROS 2 spells it — `float64`, not `double`. It **decides which other constraints are allowed at all**: `min_value` and `max_value` need a numeric type, `regex` needs a string one, and a constraint on the wrong type is refused rather than quietly ignored.',
|
|
512
|
+
/**
|
|
513
|
+
* One sentence per value. The field's own paragraph is already the
|
|
514
|
+
* hover; these answer the different question the editor asks when the
|
|
515
|
+
* cursor is on **one** offer — what is this type, and what does choosing
|
|
516
|
+
* it allow. Written for the value, so `int32` states its range and
|
|
517
|
+
* `string` says it is the type a `regex` may constrain.
|
|
518
|
+
*/
|
|
519
|
+
enumDescriptions: describeValues(parameterType.options, {
|
|
520
|
+
bool: 'A `true`/`false` flag. The one type that takes no constraint at all: no bounds, no `regex`, no `enum`.',
|
|
521
|
+
byte: 'One raw octet, `0` to `255`, carrying no character meaning. It counts as an integer here, so bounds and an `enum` apply to it.',
|
|
522
|
+
char: 'A single-octet character code, `0` to `255`. ROS 2 keeps it apart from `byte` although the width is the same, and it travels as a number rather than as a one-character string.',
|
|
523
|
+
int8: 'A whole number from `-128` to `127`.',
|
|
524
|
+
uint8: 'A whole number from `0` to `255`.',
|
|
525
|
+
int16: 'A whole number from `-32768` to `32767`.',
|
|
526
|
+
uint16: 'A whole number from `0` to `65535`.',
|
|
527
|
+
int32: 'A whole number from `-2147483648` to `2147483647` — the usual choice for a count or an index.',
|
|
528
|
+
uint32: 'A whole number from `0` to `4294967295`.',
|
|
529
|
+
int64: 'A whole number from `-9223372036854775808` to `9223372036854775807`.',
|
|
530
|
+
uint64: 'A whole number from `0` to `18446744073709551615`.',
|
|
531
|
+
float32: 'A single-precision number, roughly seven significant digits.',
|
|
532
|
+
float64: 'A double-precision number, roughly fifteen significant digits. This is what other languages call `double`; ROS 2 spells it `float64` and so does this field.',
|
|
533
|
+
string: 'Text, carried as UTF-8. One of the two types a `regex` may constrain.',
|
|
534
|
+
wstring: 'Text as wide characters, and rare — nearly every ROS 2 interface uses `string`. It takes a `regex` on the same terms.',
|
|
535
|
+
}),
|
|
536
|
+
}),
|
|
537
|
+
default: z.union([z.number(), z.string(), z.boolean()]).meta({
|
|
538
|
+
description: 'The value used when a caller omits this parameter: **without a `default` the parameter is required**, because the message cannot be built without it. It must itself satisfy `min_value`, `max_value`, `enum` and `regex` — a default the constraints reject is refused here rather than becoming the one value that reaches the robot unchecked.',
|
|
539
|
+
}).optional(),
|
|
540
|
+
min_value: z.number().meta({
|
|
541
|
+
description: 'The lowest value a caller may send; numeric types only. It is **enforced in the cloud, before anything reaches the robot** — this is where a speed limit actually holds, rather than in the app that is supposed to respect it.',
|
|
542
|
+
examples: [-0.5],
|
|
543
|
+
}).optional(),
|
|
544
|
+
max_value: z.number().meta({
|
|
545
|
+
description: 'The highest value a caller may send; numeric types only, and it may not sit below `min_value`. A reversed pair is refused at parse time, because nothing downstream catches it and every call would then fail against a bound no value can satisfy.',
|
|
546
|
+
examples: [0.5],
|
|
547
|
+
}).optional(),
|
|
548
|
+
enum: z.array(z.union([z.string(), z.number()])).min(1).meta({
|
|
549
|
+
description: 'The complete set of values a caller may send. Integer and string types only — **never a float**, because equality on floating point is unreliable and an enumerated float list is a trap that only shows up in operation. Every entry must match `type`, and a `default` must be one of them.',
|
|
550
|
+
}).optional(),
|
|
551
|
+
regex: z.string().min(1).meta({
|
|
552
|
+
description: 'A pattern the value must match; string types only. It is compiled as a JavaScript regular expression and is **not anchored**, so `[a-z]+` accepts any value that merely contains a lowercase run — a pattern meant to cover the whole value writes its own `^` and `$`.',
|
|
553
|
+
examples: ['^[a-z_]+$'],
|
|
554
|
+
}).optional(),
|
|
555
|
+
description: parameterDescription.meta({
|
|
556
|
+
description: 'What this parameter means, in the developer\'s own words, and documentation only — the robot does nothing with it. It travels into the input schema `robot_describe` publishes for this call, beside the bounds, so `type` and the range say what the value *is* and this is the only place that says what it *does*.',
|
|
557
|
+
/** The sentence `PARAMETER_SNIPPET` already places here, verbatim. */
|
|
558
|
+
examples: ['What a caller is choosing when they set this.'],
|
|
559
|
+
}),
|
|
560
|
+
})
|
|
561
|
+
.superRefine((p, ctx) => {
|
|
562
|
+
const numeric = INTEGER_TYPES.has(p.type) || FLOAT_TYPES.has(p.type);
|
|
563
|
+
const refuse = (path, why, code) => ctx.addIssue({ code: 'custom', path, message: why, ...(code ? { params: { code } } : {}) });
|
|
564
|
+
/**
|
|
565
|
+
* What a value of this parameter's declared type may look like on the
|
|
566
|
+
* wire. Integers are checked with `Number.isInteger`, which cannot tell
|
|
567
|
+
* `1.0` from `1` — nothing can, in JSON or in YAML, since both parse to
|
|
568
|
+
* the same double. `1.5` on an `int32` is the case worth catching and it
|
|
569
|
+
* is caught.
|
|
570
|
+
*/
|
|
571
|
+
const matchesType = (v) => {
|
|
572
|
+
if (p.type === 'bool')
|
|
573
|
+
return typeof v === 'boolean';
|
|
574
|
+
if (STRING_TYPES.has(p.type))
|
|
575
|
+
return typeof v === 'string';
|
|
576
|
+
if (INTEGER_TYPES.has(p.type))
|
|
577
|
+
return typeof v === 'number' && Number.isInteger(v);
|
|
578
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
579
|
+
};
|
|
580
|
+
if (!numeric && (p.min_value !== undefined || p.max_value !== undefined))
|
|
581
|
+
refuse(['min_value'], `min_value/max_value need a numeric type, not '${p.type}'`, 'constraint_not_allowed_for_type');
|
|
582
|
+
if (!STRING_TYPES.has(p.type) && p.regex !== undefined)
|
|
583
|
+
refuse(['regex'], `regex needs a string type, not '${p.type}'`, 'constraint_not_allowed_for_type');
|
|
584
|
+
if (p.enum !== undefined && !(INTEGER_TYPES.has(p.type) || STRING_TYPES.has(p.type))) {
|
|
585
|
+
refuse(['enum'], `enum needs an integer or string type, not '${p.type}'`, 'constraint_not_allowed_for_type');
|
|
586
|
+
}
|
|
587
|
+
else if (p.enum !== undefined) {
|
|
588
|
+
/**
|
|
589
|
+
* Only reached when `enum` is allowed at all. A float `enum` is one
|
|
590
|
+
* mistake, not two: reporting both codes for it would leave neither
|
|
591
|
+
* pinned to a document that produces exactly it.
|
|
592
|
+
*/
|
|
593
|
+
p.enum.forEach((v, i) => {
|
|
594
|
+
if (!matchesType(v))
|
|
595
|
+
refuse(['enum', i], `enum entry does not match type '${p.type}'`, 'value_type_mismatch');
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
if (p.default !== undefined && !matchesType(p.default))
|
|
599
|
+
refuse(['default'], `default does not match type '${p.type}'`, 'value_type_mismatch');
|
|
600
|
+
/**
|
|
601
|
+
* One of the two refusals in this file with no `params.code`, the other
|
|
602
|
+
* being `datapointChart`'s: `invalid_range` was deleted with
|
|
603
|
+
* `expected_range`, and reversed bounds are not one of the thirteen.
|
|
604
|
+
* Inventing a fourteenth here would put a code in the contracts that the
|
|
605
|
+
* cloud's table does not know.
|
|
606
|
+
*/
|
|
607
|
+
if (p.min_value !== undefined && p.max_value !== undefined && p.min_value > p.max_value)
|
|
608
|
+
refuse(['min_value'], 'min_value is greater than max_value');
|
|
609
|
+
/**
|
|
610
|
+
* A default must satisfy the same constraints a caller's value must.
|
|
611
|
+
*
|
|
612
|
+
* Without this, a default is the one way past bounds that are otherwise
|
|
613
|
+
* the enforcement point — the spec calls `min_value`/`max_value` "the
|
|
614
|
+
* speed limit that actually holds", enforced in the cloud before anything
|
|
615
|
+
* reaches the robot. But a caller who simply omits the parameter gets the
|
|
616
|
+
* default, and the bridge fills it at the template walk without
|
|
617
|
+
* re-checking bounds, deliberately: a second enforcement point there
|
|
618
|
+
* would be the weaker of two policies. So `{min_value: -1, max_value: 1,
|
|
619
|
+
* default: 99}` published 99 to a robot with no layer objecting.
|
|
620
|
+
*
|
|
621
|
+
* No `params.code`, for the same reason as the reversed bounds above.
|
|
622
|
+
*/
|
|
623
|
+
if (p.default !== undefined && matchesType(p.default)) {
|
|
624
|
+
const d = p.default;
|
|
625
|
+
if (typeof d === 'number') {
|
|
626
|
+
if (p.min_value !== undefined && d < p.min_value)
|
|
627
|
+
refuse(['default'], `default ${d} is below min_value ${p.min_value}`);
|
|
628
|
+
if (p.max_value !== undefined && d > p.max_value)
|
|
629
|
+
refuse(['default'], `default ${d} is above max_value ${p.max_value}`);
|
|
630
|
+
}
|
|
631
|
+
if (p.enum !== undefined && !p.enum.some((v) => v === d))
|
|
632
|
+
refuse(['default'], 'default is not one of the enum entries');
|
|
633
|
+
if (p.regex !== undefined && typeof d === 'string') {
|
|
634
|
+
let re;
|
|
635
|
+
try {
|
|
636
|
+
re = new RegExp(p.regex);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// An unparseable regex is its own problem and not this check's to
|
|
640
|
+
// report; skip rather than refuse the default for it.
|
|
641
|
+
}
|
|
642
|
+
if (re && !re.test(d))
|
|
643
|
+
refuse(['default'], 'default does not match regex');
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
})
|
|
647
|
+
/**
|
|
648
|
+
* The value position of one entry — `speed: ▮` under `parameters:`. It is
|
|
649
|
+
* reached by a developer adding a **second** parameter by hand, which the
|
|
650
|
+
* section snippet never covers: that one fires on the empty `parameters:` and
|
|
651
|
+
* not again.
|
|
652
|
+
*/
|
|
653
|
+
.meta({ defaultSnippets: [PARAMETER_SNIPPET] });
|
|
654
|
+
/** Parameters of one entry, keyed by name. At most 50. */
|
|
655
|
+
export const parameterMap = slugKeyed(parameterSpec)
|
|
656
|
+
.refine((m) => Object.keys(m).length <= 50, { message: 'at most 50 parameters per entry' })
|
|
657
|
+
.meta({
|
|
658
|
+
description: 'The holes in this entry\'s `message` that a caller fills, keyed by **parameter name** rather than by field path — so the name survives the field moving inside the message, and a caller sends something that means what it says. Every declared parameter must appear somewhere in the message and every `${name}` in the message must be declared; either half alone is an error.',
|
|
659
|
+
/**
|
|
660
|
+
* **One snippet reaching three positions.** `parameters:` under an action,
|
|
661
|
+
* under a service and under a publisher are all this node, so the snippet
|
|
662
|
+
* is authored once here rather than three times on the three sections.
|
|
663
|
+
* Three copies that must agree is three chances to disagree, and the
|
|
664
|
+
* export inlines this object into all three positions — which
|
|
665
|
+
* `config-snippets.test.ts` asserts rather than assumes, because the way
|
|
666
|
+
* this comes apart is somebody later giving one section a `parameters:`
|
|
667
|
+
* snippet of its own.
|
|
668
|
+
*
|
|
669
|
+
* The body is `PARAMETER_SNIPPET` under its slug key — the same skeleton
|
|
670
|
+
* the entry position below offers, and the reasons behind every value in
|
|
671
|
+
* it are written there.
|
|
672
|
+
*/
|
|
673
|
+
defaultSnippets: [underSlug('${1:speed}', PARAMETER_SNIPPET)],
|
|
674
|
+
});
|
|
675
|
+
/**
|
|
676
|
+
* Slugs no configured entry may take (spec §4.3), across **all five exposure
|
|
677
|
+
* sections at once** — slugs are one namespace, so a name reserved here is
|
|
678
|
+
* reserved everywhere.
|
|
679
|
+
*
|
|
680
|
+
* The first three are built-ins: the cloud or the bridge already publishes
|
|
681
|
+
* something under them, so a configured entry would be a second producer for
|
|
682
|
+
* one name. `history` is reserved for a different reason and is not a built-in
|
|
683
|
+
* — nothing publishes it. `GET /api/robots/:id/jobs/history` is a **literal
|
|
684
|
+
* sibling** of `GET /api/robots/:id/jobs/:slug`, so an action or service named
|
|
685
|
+
* `history` would have a job route no caller could ever reach. Reserving the
|
|
686
|
+
* name is the honest half of that: the alternative is a slug the format
|
|
687
|
+
* accepts and one route silently cannot address.
|
|
688
|
+
*
|
|
689
|
+
* **This constant is the only list.** The cloud's `validation.ts` builds its
|
|
690
|
+
* set from it and emits `reserved_slug`; `config-store.ts` reads it for the
|
|
691
|
+
* rename target; the console reads it for slug suggestion and repairs. Nothing
|
|
692
|
+
* copies the members. Note that it is NOT the enumeration of built-in
|
|
693
|
+
* datapoints — the cloud keeps that separately, and it must, now that a
|
|
694
|
+
* reserved name exists that no plane serves.
|
|
695
|
+
*
|
|
696
|
+
* **What it does not do: `robotConfigDoc` does not enforce it.** Reservation is
|
|
697
|
+
* a semantic check that belongs with the ones that need the robot's context,
|
|
698
|
+
* and it answers as a `ValidationIssue` carrying the section, the slug and a
|
|
699
|
+
* severity — which a zod issue could not, and which is what the console's
|
|
700
|
+
* repair actions read. The section descriptions below therefore say "refused
|
|
701
|
+
* when the document is validated" rather than "refused here" — the artifact
|
|
702
|
+
* ships those sentences to readers who have only the JSON Schema, and
|
|
703
|
+
* "here" would have promised them a refusal this parse does not make.
|
|
704
|
+
*/
|
|
705
|
+
export const RESERVED_SLUGS = ['bridge_state', 'robot_details', 'bridge_pressure', 'history'];
|
|
706
|
+
/**
|
|
707
|
+
* When an alert fires and when it is ok again. There is no discriminator:
|
|
708
|
+
* `resolve_at` absent means equality, present means a threshold whose
|
|
709
|
+
* direction follows from the comparison. The gap is the hysteresis, and it
|
|
710
|
+
* is therefore mandatory for thresholds — a value sitting exactly on a
|
|
711
|
+
* threshold with no gap flips on every sample.
|
|
712
|
+
*/
|
|
713
|
+
export const alertCondition = strictObject({
|
|
714
|
+
fire_at: z.union([z.number().finite(), z.string(), z.boolean()]).meta({
|
|
715
|
+
description: 'The value at which the alert starts firing. Alone it is an **equality**: it fires while the value equals `fire_at` and is ok again as soon as it differs, which is what makes a boolean or a string condition meaningful. Adding `resolve_at` turns it into a threshold instead.',
|
|
716
|
+
examples: [15, true],
|
|
717
|
+
}),
|
|
718
|
+
resolve_at: z.number().finite().meta({
|
|
719
|
+
description: 'The value at which a firing alert becomes ok again — allowed only when `fire_at` is a number, and it **must differ from it**. That gap is the hysteresis, and it makes the condition a threshold whose direction follows from which of the two values is higher. Without a gap a value sitting on the line flips on every sample.',
|
|
720
|
+
examples: [18],
|
|
721
|
+
}).optional(),
|
|
722
|
+
})
|
|
723
|
+
.superRefine((c, ctx) => {
|
|
724
|
+
if (c.resolve_at === undefined)
|
|
725
|
+
return;
|
|
726
|
+
if (typeof c.fire_at !== 'number')
|
|
727
|
+
ctx.addIssue({
|
|
728
|
+
code: 'custom',
|
|
729
|
+
path: ['resolve_at'],
|
|
730
|
+
message: 'resolve_at is only allowed when fire_at is a number',
|
|
731
|
+
params: { code: 'invalid_condition' },
|
|
732
|
+
});
|
|
733
|
+
else if (c.resolve_at === c.fire_at)
|
|
734
|
+
ctx.addIssue({
|
|
735
|
+
code: 'custom',
|
|
736
|
+
path: ['resolve_at'],
|
|
737
|
+
message: 'resolve_at must differ from fire_at',
|
|
738
|
+
params: { code: 'invalid_condition' },
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
/**
|
|
742
|
+
* The four defaults the format names, as constants.
|
|
743
|
+
*
|
|
744
|
+
* **The fields stay `.optional()`, not `.default()`** — that argument is on
|
|
745
|
+
* `serviceDescription` above and has not changed: `.default()` publishes a
|
|
746
|
+
* field as *required* in the generated JSON Schema, and absence is the
|
|
747
|
+
* single spelling of "not set" in this format. What was missing is the
|
|
748
|
+
* number itself. Left only in prose, the cloud and the console each invent
|
|
749
|
+
* their own, and the two agree until one of them is edited. The house answer
|
|
750
|
+
* is a named constant, so a consumer applying a default reads it from here.
|
|
751
|
+
*/
|
|
752
|
+
export const ALERT_SEVERITY_DEFAULT = 'warning';
|
|
753
|
+
export const ALERT_ENABLED_DEFAULT = true;
|
|
754
|
+
/** How often a value is written to history — not how often it is sent. */
|
|
755
|
+
export const RETENTION_INTERVAL_SECONDS_DEFAULT = 300;
|
|
756
|
+
/** The window a chart opens on, in minutes. Display only. */
|
|
757
|
+
export const CHART_WINDOW_MINUTES_DEFAULT = 60;
|
|
758
|
+
/**
|
|
759
|
+
* One whole alert, authored once for the two positions it is offered from:
|
|
760
|
+
* `alerts:` (under `underSlug`) and the value of one entry below it.
|
|
761
|
+
*
|
|
762
|
+
* **The body carries a condition.** `condition` is `datapointAlert`'s only
|
|
763
|
+
* required field, so a skeleton that stopped at the key would insert a document
|
|
764
|
+
* the format refuses — the one outcome worse than offering nothing, because the
|
|
765
|
+
* developer trusts the hint. It is also one decision for a reader: an alert
|
|
766
|
+
* without a condition is not a partial alert, it is nothing. The separate
|
|
767
|
+
* snippets on `condition` itself still earn their place — they are what a
|
|
768
|
+
* developer gets when they come back to an existing alert and rewrite the
|
|
769
|
+
* threshold, where this one is never offered.
|
|
770
|
+
*
|
|
771
|
+
* `severity` is left out: it is optional, absent means `warning`, and it
|
|
772
|
+
* changes no behaviour at all. Writing it would add a line that decides
|
|
773
|
+
* nothing. `enabled` likewise — absent means on, which is what an alert
|
|
774
|
+
* somebody just wrote is for.
|
|
775
|
+
*/
|
|
776
|
+
const ALERT_SNIPPET = {
|
|
777
|
+
label: 'an alert, with its condition',
|
|
778
|
+
description: 'One whole alert: the label shown in place of its key, and the threshold with the gap that keeps it from flipping on every sample.',
|
|
779
|
+
body: {
|
|
780
|
+
condition: { fire_at: 15, resolve_at: 18 },
|
|
781
|
+
name: '${2:Battery low}',
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
/**
|
|
785
|
+
* An alert's definition. Runtime state — whether it is firing, since when,
|
|
786
|
+
* with what value — is NOT here: it lives in the database and survives a
|
|
787
|
+
* restart, and it has no business in a versioned document.
|
|
788
|
+
*
|
|
789
|
+
* `severity` and `enabled` are absent-means-`ALERT_SEVERITY_DEFAULT` and
|
|
790
|
+
* absent-means-`ALERT_ENABLED_DEFAULT`; see those constants for why the
|
|
791
|
+
* default is not applied here.
|
|
792
|
+
*/
|
|
793
|
+
export const datapointAlert = strictObject({
|
|
794
|
+
condition: alertCondition.meta({
|
|
795
|
+
description: 'When this alert fires and when it is ok again. It carries **no discriminator**: upper threshold, lower threshold or equality all follow from the two values in it. Editing it resets the alert to `ok` on the next publish, while a publish that leaves it untouched keeps the running state.',
|
|
796
|
+
/**
|
|
797
|
+
* **Two snippets, and the reason is the missing discriminator.** A
|
|
798
|
+
* threshold and an equality are the same shape here — one field apart —
|
|
799
|
+
* so a single skeleton would not merely be incomplete, it would hide one
|
|
800
|
+
* of the two things this field exists to express behind a `resolve_at`
|
|
801
|
+
* the developer has to know to delete. Offering both makes the choice the
|
|
802
|
+
* schema deliberately does not name into a choice the editor does.
|
|
803
|
+
*
|
|
804
|
+
* Both values come from `fire_at`'s own `examples`, which carry exactly
|
|
805
|
+
* this pair: `15` for the threshold and `true` for the equality.
|
|
806
|
+
*
|
|
807
|
+
* **The second snippet costs the `condition:` *key* completion its body,
|
|
808
|
+
* and that price is paid knowingly.** monaco-yaml's
|
|
809
|
+
* `getInsertTextForProperty` (`yaml.worker.js:8520`) takes
|
|
810
|
+
* `defaultSnippets[0].body` only when a node carries **exactly one**
|
|
811
|
+
* snippet, so accepting `condition` from the key list writes the bare key
|
|
812
|
+
* here where every other node this wave touched writes its whole block.
|
|
813
|
+
* The two stay anyway: the value position — a developer who has written
|
|
814
|
+
* `condition:` and pressed ⏎ — is where the question "what goes here?" is
|
|
815
|
+
* actually asked, and that is the position this wave exists to answer.
|
|
816
|
+
* Merging them into one would buy back the key completion by deleting the
|
|
817
|
+
* choice the schema deliberately does not name, which is the worse trade;
|
|
818
|
+
* anyone tempted to make it should change the key-completion behaviour
|
|
819
|
+
* knowingly rather than as a side effect of tidying two snippets into one.
|
|
820
|
+
*/
|
|
821
|
+
defaultSnippets: [
|
|
822
|
+
{
|
|
823
|
+
label: 'a threshold, with its hysteresis',
|
|
824
|
+
description: 'Fires below 15 and is ok again above 18. The gap is what keeps a value sitting on the line from flipping on every sample; the direction follows from which of the two is higher, and nothing else declares it.',
|
|
825
|
+
body: { fire_at: 15, resolve_at: 18 },
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
label: 'an equality',
|
|
829
|
+
description: 'Fires while the value equals `fire_at` and is ok as soon as it differs — the only form a boolean or a string condition can take. No `resolve_at`: adding one would turn this into a threshold, and is refused unless `fire_at` is a number.',
|
|
830
|
+
body: { fire_at: true },
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
}),
|
|
834
|
+
severity: alertSeverity.meta({
|
|
835
|
+
description: 'How bad it is when this alert fires; absent means `warning`. It changes no behaviour — nothing is escalated, retried or delivered differently — it travels with the org event and colours the alert wherever it is shown.',
|
|
836
|
+
enumDescriptions: describeValues(alertSeverity.options, {
|
|
837
|
+
warning: 'Worth seeing. This is what an alert that names no severity gets.',
|
|
838
|
+
error: 'Worth acting on. The only difference from `warning` is how the alert is shown: the same event is written, at the same moment, to the same places.',
|
|
839
|
+
}),
|
|
840
|
+
}).optional(),
|
|
841
|
+
name: z.string().min(1).max(120).meta({
|
|
842
|
+
description: 'A human-readable label shown wherever this alert appears, in place of its bare key. It is not the alert\'s identity — the key is — so the label can be reworded freely, while changing the key deletes one alert and creates another.',
|
|
843
|
+
examples: ['Battery low'],
|
|
844
|
+
}).optional(),
|
|
845
|
+
enabled: z.boolean().meta({
|
|
846
|
+
description: 'Whether this alert is evaluated at all. Absent means on, the opposite of `retention.enabled`: an alert that is written down watches unless it is explicitly switched off, which is how one is silenced without losing the key that identifies it.',
|
|
847
|
+
}).optional(),
|
|
848
|
+
}).meta({
|
|
849
|
+
/**
|
|
850
|
+
* The value position of one entry — `battery_low: ▮` under `alerts:`, which
|
|
851
|
+
* is where a developer adding a **second** alert by hand stands. The section
|
|
852
|
+
* snippet fires on the empty `alerts:` and never again.
|
|
853
|
+
*/
|
|
854
|
+
defaultSnippets: [ALERT_SNIPPET],
|
|
855
|
+
});
|
|
856
|
+
/** Requires a numeric field — all four fields share that one precondition. */
|
|
857
|
+
export const datapointNumeric = strictObject({
|
|
858
|
+
scale: z.number().meta({
|
|
859
|
+
description: 'A factor the robot multiplies the raw value by before sending it (`value * scale + offset`). The arithmetic happens once, at the source, so REST, realtime and history can never disagree about a number.',
|
|
860
|
+
examples: [100],
|
|
861
|
+
}).optional(),
|
|
862
|
+
offset: z.number().meta({
|
|
863
|
+
description: 'A constant the robot adds after `scale` (`value * scale + offset`), for a value whose zero sits in the wrong place. Like `scale` it is applied before sending, so history stores the converted value and a later correction cannot reach what is already stored.',
|
|
864
|
+
examples: [-273.15],
|
|
865
|
+
}).optional(),
|
|
866
|
+
unit: z.string().max(32).meta({
|
|
867
|
+
description: 'The unit of the value **after** `scale` and `offset`, not the robot\'s own. It is shown beside the value and carried by `robot_describe` as its own field, so a model does not have to guess whether 15 means percent, volts or minutes.',
|
|
868
|
+
examples: ['%'],
|
|
869
|
+
}).optional(),
|
|
870
|
+
decimals: z.number().int().min(0).max(6).meta({
|
|
871
|
+
description: 'How many fraction digits the console shows the value with — value tile, chart axis and tooltip, and the datapoint detail page — and the number `robot_describe` reports as its own field, so a model formats the value the way the console does. Presentation only: the stored value keeps the precision it arrived with, and absent means the console\'s own default rather than zero.',
|
|
872
|
+
examples: [1],
|
|
873
|
+
}).optional(),
|
|
874
|
+
});
|
|
875
|
+
/**
|
|
876
|
+
* `interval_seconds` absent means `RETENTION_INTERVAL_SECONDS_DEFAULT`.
|
|
877
|
+
*
|
|
878
|
+
* **`enabled` absent means off**, and that direction is the deliberate one.
|
|
879
|
+
* Stored points are what a customer is billed for, so a default that silently
|
|
880
|
+
* turned history on would start charging for a value nobody asked to keep. The
|
|
881
|
+
* cheap mistake is a developer noticing a datapoint has no history and
|
|
882
|
+
* switching it on; the expensive one is nobody noticing that everything has
|
|
883
|
+
* history. Absent-means-off is also what the cloud already does — this comment
|
|
884
|
+
* exists because it was doing it without anything saying so.
|
|
885
|
+
*
|
|
886
|
+
* Not spelled `.default(false)` for the same reason as every other default in
|
|
887
|
+
* this file: the document a developer wrote is the document that is stored,
|
|
888
|
+
* and a parse that inserts fields makes the round trip a lie.
|
|
889
|
+
*/
|
|
890
|
+
export const datapointRetention = strictObject({
|
|
891
|
+
enabled: z.boolean().meta({
|
|
892
|
+
description: 'Whether values are written to the time series and become queryable. Off by default: without it the value is live only, and nobody who was not watching will ever see it.',
|
|
893
|
+
}).optional(),
|
|
894
|
+
interval_seconds: z.number().int().min(1).max(3600).meta({
|
|
895
|
+
description: 'How often a value is written to history, in seconds; absent means `300`. **Not** how often it is sent — that is `rate_throttle_hz`. Stored points are billed, so this is the direct lever on what a robot costs, and a bumper that is true for 200 ms does not appear unless a write falls inside it.',
|
|
896
|
+
examples: [300, 60],
|
|
897
|
+
}).optional(),
|
|
898
|
+
max_buffer_values: z.number().int().min(1).max(100_000).meta({
|
|
899
|
+
description: 'How many values the robot holds while the bridge is disconnected, to be pushed once it reconnects. The catch-up runs behind live telemetry and job results at a limited rate, so closing a gap never delays the present; without it the series simply has a gap, which is an honest answer.',
|
|
900
|
+
examples: [5000],
|
|
901
|
+
}).optional(),
|
|
902
|
+
});
|
|
903
|
+
/**
|
|
904
|
+
* Chart display, and display only. `default_window_minutes` absent means
|
|
905
|
+
* `CHART_WINDOW_MINUTES_DEFAULT`.
|
|
906
|
+
*
|
|
907
|
+
* The bounds are ordered here for the same reason `parameterSpec`'s are:
|
|
908
|
+
* `{y_min: 10, y_max: 1}` is a mistake nothing else catches. It used to be
|
|
909
|
+
* `invalid_range`'s job and that code was deleted with `expected_range`, so
|
|
910
|
+
* without this the document would carry a reversed axis all the way to a
|
|
911
|
+
* chart that renders empty.
|
|
912
|
+
*/
|
|
913
|
+
/**
|
|
914
|
+
* Named rather than inlined at `style:`, so that `describeValues` can read its
|
|
915
|
+
* `.options` and its per-value sentences can be keyed by value.
|
|
916
|
+
*/
|
|
917
|
+
const chartStyle = z.enum(['line', 'step']);
|
|
918
|
+
export const datapointChart = strictObject({
|
|
919
|
+
y_min: z.number().finite().meta({
|
|
920
|
+
description: 'A fixed floor for the chart\'s y axis; omitted, the axis scales to the data. `0` is a real floor and is read as `0`, never as unset.',
|
|
921
|
+
examples: [0],
|
|
922
|
+
}).optional(),
|
|
923
|
+
y_max: z.number().finite().meta({
|
|
924
|
+
description: 'A fixed ceiling for the chart\'s y axis; omitted, the axis scales to the data. It may not sit below `y_min`: a reversed pair is refused here because nothing downstream catches it, and the chart would render empty.',
|
|
925
|
+
examples: [100],
|
|
926
|
+
}).optional(),
|
|
927
|
+
style: chartStyle.meta({
|
|
928
|
+
description: 'How the drawing joins two samples, which is not a matter of taste. `line` claims the value moved evenly between them, roughly true of a temperature or a charge; `step` holds and then jumps, the only honest drawing for a mode, a switch or a counter, where a straight line would show values that never existed.',
|
|
929
|
+
enumDescriptions: describeValues(chartStyle.options, {
|
|
930
|
+
line: 'Straight lines between samples, so the drawing claims the value moved evenly from one to the next. Right for a quantity that really is continuous — a temperature, a charge level — where a reading taken between two samples would have landed somewhere on that line.',
|
|
931
|
+
step: 'Each value is held until the next one arrives, then jumps to it. Right for anything that does not slide between its values — a mode, a state, a switch, a counter — where a sloped line would draw readings the robot never reported.',
|
|
932
|
+
}),
|
|
933
|
+
}).optional(),
|
|
934
|
+
default_window_minutes: z.number().int().min(1).max(43_200).meta({
|
|
935
|
+
description: 'How far back the chart reaches when it is first opened, in minutes; absent means `60`. Only the starting zoom: a viewer may look further, and nothing about what is stored follows from it.',
|
|
936
|
+
examples: [1440],
|
|
937
|
+
}).optional(),
|
|
938
|
+
})
|
|
939
|
+
.superRefine((c, ctx) => {
|
|
940
|
+
if (c.y_min !== undefined && c.y_max !== undefined && c.y_min > c.y_max)
|
|
941
|
+
ctx.addIssue({ code: 'custom', path: ['y_min'], message: 'y_min is greater than y_max' });
|
|
942
|
+
});
|
|
943
|
+
/**
|
|
944
|
+
* The ceiling lives here once. `rest.ts`'s `datapointDescriptor` reuses it,
|
|
945
|
+
* so the two cannot drift apart the way a number spelled out twice always
|
|
946
|
+
* eventually does. `0` is deliberately admitted — zero and "omitted"
|
|
947
|
+
* (`datapointConfig`) or `null` (`datapointDescriptor`) are the same fact,
|
|
948
|
+
* "no throttling", not a refused value: `.positive()` here would exclude
|
|
949
|
+
* the very thing this field's own absence already means.
|
|
950
|
+
*/
|
|
951
|
+
export const rateThrottleHz = z.number().nonnegative().max(20);
|
|
952
|
+
/**
|
|
953
|
+
* One value the robot publishes, authored once for the two positions it is
|
|
954
|
+
* offered from: `datapoints:` (under `underSlug`) and the value of one entry
|
|
955
|
+
* below it. The four required-to-be-useful fields and nothing else; everything
|
|
956
|
+
* optional arrives by ordinary key completion, which works and never stopped.
|
|
957
|
+
*/
|
|
958
|
+
const DATAPOINT_SNIPPET = {
|
|
959
|
+
label: 'a datapoint',
|
|
960
|
+
description: 'One value the robot publishes: one field of one topic.',
|
|
961
|
+
body: {
|
|
962
|
+
topic: '${2:/battery}',
|
|
963
|
+
type: '${3:sensor_msgs/msg/BatteryState}',
|
|
964
|
+
field: '${4:voltage}',
|
|
965
|
+
description: '${5:What this value is, for whoever meets it in the console.}',
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
/**
|
|
969
|
+
* The same, with the three sub-blocks a plain datapoint leaves out — what the
|
|
970
|
+
* number means, what is kept of it, and how it is drawn.
|
|
971
|
+
*
|
|
972
|
+
* `chart.style` is the literal `line` and not the choice the `chart:` node
|
|
973
|
+
* itself offers: this body is a battery percentage, where `line` is not a
|
|
974
|
+
* guess.
|
|
975
|
+
*/
|
|
976
|
+
const NUMERIC_DATAPOINT_SNIPPET = {
|
|
977
|
+
label: 'a numeric datapoint, with history and a chart',
|
|
978
|
+
description: 'A number with its unit, what is kept of it and how it is drawn — the blocks a plain datapoint leaves out.',
|
|
979
|
+
body: {
|
|
980
|
+
topic: '${2:/battery}',
|
|
981
|
+
type: '${3:sensor_msgs/msg/BatteryState}',
|
|
982
|
+
field: '${4:percentage}',
|
|
983
|
+
description: '${5:What this value is, for whoever meets it in the console.}',
|
|
984
|
+
/**
|
|
985
|
+
* The quotes inside `unit` are part of the inserted text and are not
|
|
986
|
+
* decoration. A body string is written into the document verbatim, and `%`
|
|
987
|
+
* is a YAML directive indicator: measured with `yaml` 2.9.0, `unit: %` is a
|
|
988
|
+
* **syntax error** ("Plain value cannot start with directive indicator
|
|
989
|
+
* character %") while `unit: "%"` parses to `%`. Nothing between here and
|
|
990
|
+
* the buffer quotes a scalar for us.
|
|
991
|
+
*/
|
|
992
|
+
numeric: { scale: 100, unit: '"%"', decimals: 1 },
|
|
993
|
+
retention: { enabled: true, interval_seconds: 300 },
|
|
994
|
+
chart: { y_min: 0, y_max: 100, style: 'line' },
|
|
995
|
+
},
|
|
996
|
+
};
|
|
997
|
+
/**
|
|
998
|
+
* One exposed datapoint: one field of a topic, or the whole topic
|
|
999
|
+
* (`field` omitted). Never several topics.
|
|
1000
|
+
*
|
|
1001
|
+
* `rate_throttle_hz` is an upper bound, not a clock — the bridge drops what
|
|
1002
|
+
* arrives too fast and never repeats a value to manufacture a rate. The
|
|
1003
|
+
* ceiling is 20: an app's surface has no use for more, and a control loop
|
|
1004
|
+
* belongs on a tool that reads at the robot.
|
|
1005
|
+
*/
|
|
1006
|
+
export const datapointConfig = strictObject({
|
|
1007
|
+
topic: rosName.meta({
|
|
1008
|
+
description: 'The ROS topic this datapoint reads, as an absolute graph name. One datapoint reads **one** topic: a value assembled from two topics is not expressible here.',
|
|
1009
|
+
patternErrorMessage: ROS_NAME_RULE,
|
|
1010
|
+
examples: ['/battery'],
|
|
1011
|
+
}),
|
|
1012
|
+
type: rosTypeName.meta({
|
|
1013
|
+
description: 'The message type carried by `topic`, spelled the way ROS 2 spells it, with the `msg` segment in the middle — `sensor_msgs/msg/BatteryState`, never `sensor_msgs/BatteryState`. It is declared here rather than discovered, so a configuration can be written for a robot that has never been connected; the cloud checks it against the robot\'s own message definitions only once one is there.',
|
|
1014
|
+
patternErrorMessage: ROS_TYPE_NAME_RULE,
|
|
1015
|
+
examples: ['sensor_msgs/msg/BatteryState'],
|
|
1016
|
+
}),
|
|
1017
|
+
field: fieldPath.meta({
|
|
1018
|
+
description: 'A dotted path into the message naming the single value this datapoint carries, each segment indexing at most one array level — `ranges[0]`, never `ranges[0][1]`, because ROS 2 has no nested arrays. Without it the datapoint is the whole message, and `numeric`, `chart` and `alerts` are then refused.',
|
|
1019
|
+
patternErrorMessage: FIELD_PATH_RULE,
|
|
1020
|
+
examples: ['voltage', 'pose.position.x', 'ranges[0]'],
|
|
1021
|
+
}).optional(),
|
|
1022
|
+
rate_throttle_hz: rateThrottleHz.meta({
|
|
1023
|
+
description: 'A ceiling on how often this datapoint is sent, in hertz. Omitted or `0` means no throttling. It is **a ceiling, not a clock**: a slow topic stays slow, a value is never repeated to manufacture a rate, and within a window the newest value wins. The bridge enforces it, so the robot\'s bandwidth is genuinely saved.',
|
|
1024
|
+
examples: [2, 0.5],
|
|
1025
|
+
}).optional(),
|
|
1026
|
+
description: serviceDescription.meta({
|
|
1027
|
+
description: 'Prose about what this value is, for whoever meets it in the console later. It changes nothing the robot does, so a publish that touches only it pushes no configuration at all — but it is carried verbatim into `robot_describe`, where a model that has never seen this robot reads it. The datapoint is offered whenever the role grants it; without one it is offered with `description: null` and the model has less to go on, as for actions, services, publishers and cameras. Omission is the only way to say nothing; an empty string is refused, here and on all five.',
|
|
1028
|
+
/**
|
|
1029
|
+
* The sentence both datapoint snippets already place here, verbatim. Its
|
|
1030
|
+
* four siblings — an action's, a service's, a publisher's, a camera's —
|
|
1031
|
+
* each carry the sentence from their own snippet body, so this position
|
|
1032
|
+
* was the one description in the format offering nothing; a second wording
|
|
1033
|
+
* invented here would have been the drift instead.
|
|
1034
|
+
*/
|
|
1035
|
+
examples: ['What this value is, for whoever meets it in the console.'],
|
|
1036
|
+
}),
|
|
1037
|
+
numeric: datapointNumeric.meta({
|
|
1038
|
+
description: 'Arithmetic and formatting for a numeric value. `scale` and `offset` are applied **on the robot**, before sending, which is why REST, realtime and history all carry identical numbers. `unit` and `decimals` change nothing the robot does, so a publish that touches only those pushes no configuration.',
|
|
1039
|
+
/**
|
|
1040
|
+
* The quotes inside `unit` are inserted text, not decoration, for the
|
|
1041
|
+
* reason spelled out on the `datapoints` snippet below: a body string is
|
|
1042
|
+
* written to the buffer verbatim and a bare `%` is a YAML directive
|
|
1043
|
+
* indicator, so `unit: %` is a syntax error where `unit: "%"` parses.
|
|
1044
|
+
*
|
|
1045
|
+
* **`offset` is not in the body, and that is a choice rather than an
|
|
1046
|
+
* oversight.** All four fields carry `examples`, but they were authored
|
|
1047
|
+
* per field and from two different conversions: `scale: 100` with
|
|
1048
|
+
* `unit: '%'` is a 0..1 fraction shown as a percentage, while
|
|
1049
|
+
* `offset: -273.15` is kelvin as celsius. A body holding both would
|
|
1050
|
+
* insert arithmetic that means nothing and that a developer has to
|
|
1051
|
+
* unpick before it means anything. The rule this file follows is that a
|
|
1052
|
+
* skeleton carries what a developer opening the block almost certainly
|
|
1053
|
+
* wants, at the node's own example values; the remaining keys arrive by
|
|
1054
|
+
* ordinary key completion, which works here and never stopped working —
|
|
1055
|
+
* the position that was silent is the *value* after `numeric:`.
|
|
1056
|
+
*/
|
|
1057
|
+
defaultSnippets: [{
|
|
1058
|
+
label: 'a unit, and the arithmetic that produces it',
|
|
1059
|
+
description: 'A 0..1 fraction sent as a percentage to one decimal. `scale` is applied on the robot before sending, so history stores the converted value and a later correction cannot reach what is already stored.',
|
|
1060
|
+
body: { scale: 100, unit: '"%"', decimals: 1 },
|
|
1061
|
+
}],
|
|
1062
|
+
}).optional(),
|
|
1063
|
+
retention: datapointRetention.meta({
|
|
1064
|
+
description: 'What outlives the moment: whether this value is written to the time series, how often, and how many points the robot buffers while the bridge is away. Absent means no history at all — the value is live only.',
|
|
1065
|
+
/**
|
|
1066
|
+
* `enabled: true` is the only value that makes opening this block mean
|
|
1067
|
+
* anything — absent already means off, so a skeleton inserting `false`
|
|
1068
|
+
* would be a block that does nothing. It is a boolean and carries no
|
|
1069
|
+
* `examples`; the direction comes from the schema comment above, which
|
|
1070
|
+
* says why absent-means-off is the deliberate one.
|
|
1071
|
+
*
|
|
1072
|
+
* `interval_seconds: 300` restates the format's own default, on purpose:
|
|
1073
|
+
* stored points are what a customer is billed for, so this is the direct
|
|
1074
|
+
* lever on what a robot costs, and a developer who never sees the field
|
|
1075
|
+
* never tunes it.
|
|
1076
|
+
*
|
|
1077
|
+
* **This body carries `max_buffer_values` and the composite `datapoints`
|
|
1078
|
+
* snippet's `retention:` does not, deliberately.** The two answer
|
|
1079
|
+
* different questions and the difference is the answer to each: the
|
|
1080
|
+
* composite says *what a datapoint looks like*, where retention is one
|
|
1081
|
+
* of three sub-blocks and the robot-side buffer is a tuning detail that
|
|
1082
|
+
* would bury the shape it is there to show; this node is reached only by
|
|
1083
|
+
* a developer who has written `retention:` and asked what goes in it, and
|
|
1084
|
+
* for that question the buffer is a third of the answer. Neither is the
|
|
1085
|
+
* corrected version of the other.
|
|
1086
|
+
*/
|
|
1087
|
+
defaultSnippets: [{
|
|
1088
|
+
label: 'history, on, with its interval and buffer',
|
|
1089
|
+
description: 'Writes this value to the time series every 300 seconds and holds 5000 points on the robot while the bridge is away. Stored points are billed, so both numbers are worth choosing rather than inheriting.',
|
|
1090
|
+
body: { enabled: true, interval_seconds: 300, max_buffer_values: 5000 },
|
|
1091
|
+
}],
|
|
1092
|
+
}).optional(),
|
|
1093
|
+
chart: datapointChart.meta({
|
|
1094
|
+
description: 'How the console draws this value over time: axis bounds, whether the line interpolates or steps, and the window a chart opens on. **Display only** — it changes no stored value, no alert and nothing the robot does, so a publish that touches only it pushes no configuration.',
|
|
1095
|
+
/**
|
|
1096
|
+
* `style` is a **choice**, not a literal, for the reason the camera
|
|
1097
|
+
* source's `type` is one: the format offers a closed pair, the right
|
|
1098
|
+
* answer depends on what the datapoint is, and the snippet cannot know.
|
|
1099
|
+
* Its own description says the two are not a matter of taste — `line`
|
|
1100
|
+
* claims the value moved evenly between two samples, `step` holds and
|
|
1101
|
+
* jumps, and `step` is the only honest drawing for a mode, a switch or a
|
|
1102
|
+
* counter. A snippet that picked `line` would draw values that never
|
|
1103
|
+
* existed, and nothing would object: both are valid, no diagnostic
|
|
1104
|
+
* fires, and the chart looks plausible.
|
|
1105
|
+
*
|
|
1106
|
+
* `Choice.toString()` is the first option, so a developer who tabs past
|
|
1107
|
+
* this gets `line`, which is right for the continuous values most charts
|
|
1108
|
+
* carry; one who opens the picker sees that `step` exists at all.
|
|
1109
|
+
*
|
|
1110
|
+
* The composite snippet on `datapoints` writes `style: 'line'` as a
|
|
1111
|
+
* literal and stays that way — its body is a battery percentage, where
|
|
1112
|
+
* `line` is not a guess.
|
|
1113
|
+
*
|
|
1114
|
+
* **`default_window_minutes` is left out, on the same rule that leaves
|
|
1115
|
+
* `offset` out of `numeric` above**, and it is said here so that the two
|
|
1116
|
+
* omissions read alike: it has its own `examples` (`1440`) and this
|
|
1117
|
+
* node's description names it, but it is the one field of the four that
|
|
1118
|
+
* decides nothing about the drawing — absent means 60, a viewer may look
|
|
1119
|
+
* further whatever it says, and nothing about what is stored follows from
|
|
1120
|
+
* it. Key completion offers it inside the block the moment anyone wants
|
|
1121
|
+
* it; the position that was silent is the *value* after `chart:`.
|
|
1122
|
+
*/
|
|
1123
|
+
defaultSnippets: [{
|
|
1124
|
+
label: 'axis bounds, and how two samples are joined',
|
|
1125
|
+
description: 'A fixed 0..100 axis rather than one that scales to the data, and a choice between interpolating and stepping between samples — which is not a matter of taste.',
|
|
1126
|
+
body: { y_min: 0, y_max: 100, style: '${1|line,step|}' },
|
|
1127
|
+
}],
|
|
1128
|
+
}).optional(),
|
|
1129
|
+
alerts: slugKeyed(datapointAlert).meta({
|
|
1130
|
+
description: 'Alerts watching this value, keyed by slug; each moves between `ok` and `firing` and writes an org event on every transition. No mail is sent. **The key is the identity**, so renaming an alert is a delete plus a create: its runtime state is lost, and an alert that is still true fires again.',
|
|
1131
|
+
/**
|
|
1132
|
+
* The body is `ALERT_SNIPPET` under its slug key — the same skeleton the
|
|
1133
|
+
* entry position offers, and the reasons behind every value in it are
|
|
1134
|
+
* written there. **The key is in the wrapper**, and it is the alert's
|
|
1135
|
+
* identity: renaming it is a delete plus a create.
|
|
1136
|
+
*/
|
|
1137
|
+
defaultSnippets: [underSlug('${1:battery_low}', ALERT_SNIPPET)],
|
|
1138
|
+
}).optional(),
|
|
1139
|
+
})
|
|
1140
|
+
.superRefine((d, ctx) => {
|
|
1141
|
+
if (d.field !== undefined)
|
|
1142
|
+
return;
|
|
1143
|
+
for (const group of ['numeric', 'chart', 'alerts']) {
|
|
1144
|
+
if (d[group] !== undefined)
|
|
1145
|
+
ctx.addIssue({
|
|
1146
|
+
code: 'custom',
|
|
1147
|
+
path: [group],
|
|
1148
|
+
message: `${group} needs a single field; without 'field' the value is the whole message`,
|
|
1149
|
+
params: { code: 'requires_single_field' },
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
})
|
|
1153
|
+
/**
|
|
1154
|
+
* The value position of one entry — `battery: ▮` under `datapoints:`, where a
|
|
1155
|
+
* developer adding a **second** datapoint by hand stands. Both skeletons the
|
|
1156
|
+
* section offers, in the same order, so the choice between a plain value and
|
|
1157
|
+
* a fully-equipped one is the same choice at both positions.
|
|
1158
|
+
*/
|
|
1159
|
+
.meta({ defaultSnippets: [DATAPOINT_SNIPPET, NUMERIC_DATAPOINT_SNIPPET] });
|
|
1160
|
+
/**
|
|
1161
|
+
* Whether a template holds an explicit `null` anywhere inside it.
|
|
1162
|
+
*
|
|
1163
|
+
* At **any depth**, and the depth is the whole point. Every other field in
|
|
1164
|
+
* this file is `.optional()` rather than `.nullable()`, so zod refuses `null`
|
|
1165
|
+
* at each of them for free. A message body is the one exception — it is
|
|
1166
|
+
* `z.unknown()`, because a template can be any shape a ROS message can — so
|
|
1167
|
+
* nothing below the top of it is checked by the type at all.
|
|
1168
|
+
*
|
|
1169
|
+
* That gap was measured and missed once already: a top-level `message: null`
|
|
1170
|
+
* was refused while `message: { linear: { x: null } }` parsed clean, and a
|
|
1171
|
+
* check written to catch exactly this was deleted on the strength of six test
|
|
1172
|
+
* cases, none of which reached inside a body.
|
|
1173
|
+
*
|
|
1174
|
+
* Walked with an explicit stack and a seen-set, not recursion: a YAML anchor
|
|
1175
|
+
* can make a template both very deep and genuinely cyclic, and a developer can
|
|
1176
|
+
* legitimately write one.
|
|
1177
|
+
*/
|
|
1178
|
+
function holdsExplicitNull(node) {
|
|
1179
|
+
const stack = [node];
|
|
1180
|
+
const seen = new WeakSet();
|
|
1181
|
+
while (stack.length > 0) {
|
|
1182
|
+
const current = stack.pop();
|
|
1183
|
+
if (current === null)
|
|
1184
|
+
return true;
|
|
1185
|
+
if (typeof current !== 'object')
|
|
1186
|
+
continue;
|
|
1187
|
+
if (seen.has(current))
|
|
1188
|
+
continue;
|
|
1189
|
+
seen.add(current);
|
|
1190
|
+
stack.push(...(Array.isArray(current) ? current : Object.values(current)));
|
|
1191
|
+
}
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* A message template: the goal, request or published message, written out in
|
|
1196
|
+
* full. Literals are fixed; `${name}` is a hole a caller fills.
|
|
1197
|
+
*
|
|
1198
|
+
* The shape cannot be narrower than `unknown` here — it is the shape of an
|
|
1199
|
+
* arbitrary ROS message, which only the robot's own type definition knows.
|
|
1200
|
+
* What CAN be checked here is the placeholder grammar; everything else is
|
|
1201
|
+
* checked in the cloud against the introspected type.
|
|
1202
|
+
*
|
|
1203
|
+
* **`null` is refused, at every depth of this position.** Omission is the
|
|
1204
|
+
* only spelling of "not set" in this format, and a bare `z.unknown()` made
|
|
1205
|
+
* every message position the one place that also accepted the second
|
|
1206
|
+
* spelling: `publishers.p.message: null`, `failsafe.message: null`,
|
|
1207
|
+
* `actions.a.message: null` and `messages: {stop: null}` all parsed. Every
|
|
1208
|
+
* other field gets this from its own type refusing `null`; this one has no
|
|
1209
|
+
* type to get it from, so it says it here.
|
|
1210
|
+
*
|
|
1211
|
+
* The refusal is a refinement and therefore **invisible in the JSON Schema
|
|
1212
|
+
* artifact**, which publishes this position as `{}`. The artifact says what
|
|
1213
|
+
* the shape is, not what the parser refuses; a consumer that validates
|
|
1214
|
+
* against the artifact instead of against this schema does not get it.
|
|
1215
|
+
*/
|
|
1216
|
+
export const messageTemplate = z.unknown().refine((v) => !holdsExplicitNull(v), {
|
|
1217
|
+
message: 'null is not a value; omit the key instead',
|
|
1218
|
+
params: { code: 'explicit_null' },
|
|
1219
|
+
}).meta({
|
|
1220
|
+
description: 'The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of "not set".',
|
|
1221
|
+
});
|
|
1222
|
+
/** `${name}` and nothing else. A bare word is always a literal. */
|
|
1223
|
+
export const PLACEHOLDER_RE = /^\$\{([a-z][a-z0-9]*(?:_[a-z0-9]+)*)\}$/;
|
|
1224
|
+
export const messageRef = z.string().regex(PLACEHOLDER_RE).meta({
|
|
1225
|
+
description: 'A reference to a shared message: `${name}` and nothing else, which is what separates a reference from a literal — a bare word stays a literal even when it happens to match a declared name. Whether that name is declared, and whether it points at a body holding a second reference, are questions about the whole document and are answered in the cloud.',
|
|
1226
|
+
});
|
|
1227
|
+
/**
|
|
1228
|
+
* What may stand at a `message:` position: a shared message by name
|
|
1229
|
+
* (`${name}`), or an inline template. Position decides which — directly after
|
|
1230
|
+
* `message:` a `${name}` resolves to a shared message, inside a body it
|
|
1231
|
+
* resolves to a parameter.
|
|
1232
|
+
*
|
|
1233
|
+
* **This is `messageTemplate`, not a union with `messageRef`, and that is a
|
|
1234
|
+
* correction rather than a simplification.** It was written as
|
|
1235
|
+
* `z.union([messageRef, messageTemplate])`, whose second member accepts
|
|
1236
|
+
* everything the first does: the union could never refuse, never narrowed
|
|
1237
|
+
* anything (`string | unknown` is `unknown`), and published as
|
|
1238
|
+
* `anyOf: [{pattern: …}, {}]` — an artifact that claims a distinction no
|
|
1239
|
+
* validator makes. The distinction is real but it is not a shape distinction:
|
|
1240
|
+
* a `${name}` here means a reference and elsewhere means a parameter, and
|
|
1241
|
+
* only the cloud can say whether that name is a defined message
|
|
1242
|
+
* (`unknown_message`) or a nested one (`nested_message_reference`).
|
|
1243
|
+
*
|
|
1244
|
+
* `messageRef` stays exported as the predicate that decides it. It is what a
|
|
1245
|
+
* consumer applies to a body to ask "is this a reference?"; it is not what
|
|
1246
|
+
* validates one.
|
|
1247
|
+
*/
|
|
1248
|
+
export const messageBody = messageTemplate;
|
|
1249
|
+
/**
|
|
1250
|
+
* Every placeholder name in a template, at any depth.
|
|
1251
|
+
*
|
|
1252
|
+
* **An explicit stack, not recursion, and the reason is `safeParse`'s
|
|
1253
|
+
* contract.** This runs inside `publisherConfig`'s failsafe refinement, so a
|
|
1254
|
+
* `RangeError: Maximum call stack size exceeded` did not stay here: it
|
|
1255
|
+
* propagated out of `safeParse`, which is specified to return a result and
|
|
1256
|
+
* not to throw. Measured on the recursive version — fine at 8 000 levels of
|
|
1257
|
+
* nesting, throwing at 20 000 — and a flow-style YAML one-liner reaches that
|
|
1258
|
+
* in about 120 KB of input. A draft PUT would have answered 500 where it
|
|
1259
|
+
* meant 400.
|
|
1260
|
+
*
|
|
1261
|
+
* `seen` is not an optimisation. YAML anchors can express a cycle
|
|
1262
|
+
* (`&a { b: *a }`), and the parser resolves an alias to the same object, so
|
|
1263
|
+
* without it the loop that fixed the overflow would hang instead — the
|
|
1264
|
+
* failure mode a stack trades for, made worse by being silent.
|
|
1265
|
+
*
|
|
1266
|
+
* The array branch is explicit, not necessary: `Object.values()` on an array
|
|
1267
|
+
* yields the same elements. It is here so the walk reads as covering both
|
|
1268
|
+
* shapes; a reader does not have to know that property of `Object.values`.
|
|
1269
|
+
*/
|
|
1270
|
+
export function placeholderNames(node, found = new Set()) {
|
|
1271
|
+
const stack = [node];
|
|
1272
|
+
const seen = new WeakSet();
|
|
1273
|
+
while (stack.length > 0) {
|
|
1274
|
+
const current = stack.pop();
|
|
1275
|
+
if (typeof current === 'string') {
|
|
1276
|
+
const m = PLACEHOLDER_RE.exec(current);
|
|
1277
|
+
if (m)
|
|
1278
|
+
found.add(m[1]);
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
if (!current || typeof current !== 'object')
|
|
1282
|
+
continue;
|
|
1283
|
+
if (seen.has(current))
|
|
1284
|
+
continue;
|
|
1285
|
+
seen.add(current);
|
|
1286
|
+
if (Array.isArray(current)) {
|
|
1287
|
+
for (const item of current)
|
|
1288
|
+
stack.push(item);
|
|
1289
|
+
}
|
|
1290
|
+
else {
|
|
1291
|
+
for (const value of Object.values(current))
|
|
1292
|
+
stack.push(value);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return found;
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* One shared message body, authored once for the two positions it is offered
|
|
1299
|
+
* from: `messages:` (under `underSlug`) and the value of one entry below it.
|
|
1300
|
+
*
|
|
1301
|
+
* `param()` and not a bare `${speed}` — the backslash is why the hole survives
|
|
1302
|
+
* the insert; see `param` for the measurement.
|
|
1303
|
+
*/
|
|
1304
|
+
const SHARED_MESSAGE_SNIPPET = {
|
|
1305
|
+
label: 'a shared message',
|
|
1306
|
+
description: 'One reusable body, with one parameter hole in it.',
|
|
1307
|
+
body: {
|
|
1308
|
+
linear: { x: param('speed') },
|
|
1309
|
+
angular: { z: 0 },
|
|
1310
|
+
},
|
|
1311
|
+
};
|
|
1312
|
+
/**
|
|
1313
|
+
* The value position of one shared message — `drive: ▮` under `messages:`.
|
|
1314
|
+
*
|
|
1315
|
+
* **This is a clone of `messageTemplate` and not `messageTemplate` itself,
|
|
1316
|
+
* deliberately.** `messageBody` *is* `messageTemplate`, the same instance, so a
|
|
1317
|
+
* `defaultSnippets` written onto it would also reach `message:` under an
|
|
1318
|
+
* action, a service and a publisher — where the body below is a zero twist
|
|
1319
|
+
* offered as the skeleton for a nav2 goal, which is worse than the silence it
|
|
1320
|
+
* replaced. Those three positions take arbitrary content shaped by the entry's
|
|
1321
|
+
* own ROS type, and nothing here knows it. `.meta()` clones rather than
|
|
1322
|
+
* mutating, so `messageTemplate` is untouched, and the template's own
|
|
1323
|
+
* `description` reaches this node **without being restated** — a second copy of
|
|
1324
|
+
* that paragraph would be a second thing to keep true.
|
|
1325
|
+
*
|
|
1326
|
+
* **How the description gets here is zod behaviour, not something written
|
|
1327
|
+
* below.** Measured against zod 4.4.3: `.meta()` on an already-registered
|
|
1328
|
+
* schema merges rather than replaces, and the clone resolves the parent's entry
|
|
1329
|
+
* *lazily* — a clone taken before the parent was registered at all still sees
|
|
1330
|
+
* the parent's description afterwards. So no spread is needed and there is no
|
|
1331
|
+
* evaluation-order hazard. This was first written as
|
|
1332
|
+
* `.meta({ ...messageTemplate.meta(), … })`; dropping the spread was measured
|
|
1333
|
+
* to change nothing in the export, and two mechanisms for one description is
|
|
1334
|
+
* the shape this file removes rather than adds.
|
|
1335
|
+
*
|
|
1336
|
+
* It is undocumented behaviour all the same, so `config-snippets.test.ts`
|
|
1337
|
+
* asserts this node still carries a description and that it is the same string
|
|
1338
|
+
* as the `message:` position — if a zod release stops merging, that is a red
|
|
1339
|
+
* test rather than a hover that silently went blank.
|
|
1340
|
+
*/
|
|
1341
|
+
const sharedMessageBody = messageTemplate.meta({ defaultSnippets: [SHARED_MESSAGE_SNIPPET] });
|
|
1342
|
+
/**
|
|
1343
|
+
* Reusable message bodies, keyed by name. A shared message may hold
|
|
1344
|
+
* placeholders; whoever inserts it declares the parameters. It may NOT
|
|
1345
|
+
* insert another — that excludes cycles and lets every check look at exactly
|
|
1346
|
+
* one body instead of walking a reference tree.
|
|
1347
|
+
*/
|
|
1348
|
+
export const messageMap = slugKeyed(sharedMessageBody)
|
|
1349
|
+
.refine((m) => Object.keys(m).length <= 200, { message: 'at most 200 shared messages' })
|
|
1350
|
+
.meta({
|
|
1351
|
+
description: 'Reusable message bodies, keyed by name. A body is inserted by writing `${name}` directly after `message:`, may hold placeholders of its own, and **may not insert another** — which rules out cycles and lets every check look at exactly one body.',
|
|
1352
|
+
});
|
|
1353
|
+
/**
|
|
1354
|
+
* One action, authored once for the two positions it is offered from:
|
|
1355
|
+
* `actions:` (under `underSlug`) and the value of one entry below it. The three
|
|
1356
|
+
* required fields at the node's own `examples`; `message` and `parameters`
|
|
1357
|
+
* depend on the action type and are left to key completion.
|
|
1358
|
+
*/
|
|
1359
|
+
const ACTION_SNIPPET = {
|
|
1360
|
+
label: 'an action',
|
|
1361
|
+
description: 'One thing the robot does on request, reported as a job with progress.',
|
|
1362
|
+
body: {
|
|
1363
|
+
ros_name: '${2:/navigate_to_pose}',
|
|
1364
|
+
type: '${3:nav2_msgs/action/NavigateToPose}',
|
|
1365
|
+
description: '${4:Drives to a target pose on the map.}',
|
|
1366
|
+
},
|
|
1367
|
+
};
|
|
1368
|
+
/**
|
|
1369
|
+
* An action the robot can be asked to perform (spec §4.2, §11.3). At most one
|
|
1370
|
+
* job runs per action slug; a second call is refused `busy`, and every
|
|
1371
|
+
* observer of the slug watches the same job.
|
|
1372
|
+
*/
|
|
1373
|
+
export const actionConfig = strictObject({
|
|
1374
|
+
ros_name: rosName.meta({
|
|
1375
|
+
description: 'The action server on the robot, as an absolute graph name — this is what the bridge sends the goal to. Clients never see it: they address this entry by its slug, so a server can be renamed on the robot without a single app changing.',
|
|
1376
|
+
patternErrorMessage: ROS_NAME_RULE,
|
|
1377
|
+
examples: ['/navigate_to_pose'],
|
|
1378
|
+
}),
|
|
1379
|
+
type: rosTypeName.meta({
|
|
1380
|
+
description: 'The action type `ros_name` implements, with the `action` segment in the middle — `nav2_msgs/action/NavigateToPose`, never `nav2_msgs/NavigateToPose`. Declared rather than introspected, so an action can be configured for a robot that has never connected; the cloud checks it against the robot\'s own definitions only once one is there.',
|
|
1381
|
+
patternErrorMessage: ROS_TYPE_NAME_RULE,
|
|
1382
|
+
examples: ['nav2_msgs/action/NavigateToPose'],
|
|
1383
|
+
}),
|
|
1384
|
+
message: messageBody.optional(),
|
|
1385
|
+
parameters: parameterMap.optional(),
|
|
1386
|
+
description: serviceDescription.meta({
|
|
1387
|
+
description: 'What this action does, in the developer\'s own words — documentation for the console and for MCP clients, which is all it is: the robot does nothing with it. It is carried verbatim into `robot_describe` and read by a model that has never seen this robot. The action is offered whenever the role grants it; without one it is offered with `description: null`, and the model has nothing but the slug.',
|
|
1388
|
+
examples: ['Drives to a target pose on the map.'],
|
|
1389
|
+
}),
|
|
1390
|
+
}).meta({
|
|
1391
|
+
/** The value position of one entry — `navigate: ▮` under `actions:`. */
|
|
1392
|
+
defaultSnippets: [ACTION_SNIPPET],
|
|
1393
|
+
});
|
|
1394
|
+
/**
|
|
1395
|
+
* One service, authored once for the two positions it is offered from:
|
|
1396
|
+
* `services:` (under `underSlug`) and the value of one entry below it. The
|
|
1397
|
+
* example is `Trigger`, whose request has no fields — so `message` and
|
|
1398
|
+
* `parameters` are genuinely absent rather than merely left out.
|
|
1399
|
+
*/
|
|
1400
|
+
const SERVICE_SNIPPET = {
|
|
1401
|
+
label: 'a service',
|
|
1402
|
+
description: 'One request, one reply, no progress in between.',
|
|
1403
|
+
body: {
|
|
1404
|
+
ros_name: '${2:/reset_odometry}',
|
|
1405
|
+
type: '${3:std_srvs/srv/Trigger}',
|
|
1406
|
+
description: '${4:Resets odometry to the origin.}',
|
|
1407
|
+
},
|
|
1408
|
+
};
|
|
1409
|
+
/** A ROS service call with validated parameters (spec §4.2). */
|
|
1410
|
+
export const serviceConfig = strictObject({
|
|
1411
|
+
ros_name: rosName.meta({
|
|
1412
|
+
description: 'The ROS service the robot answers on, as an absolute graph name. The call is one request and one reply with no progress in between, so whatever this service does has to finish inside that reply; anything long-running belongs in `actions`.',
|
|
1413
|
+
patternErrorMessage: ROS_NAME_RULE,
|
|
1414
|
+
examples: ['/reset_odometry'],
|
|
1415
|
+
}),
|
|
1416
|
+
type: rosTypeName.meta({
|
|
1417
|
+
description: 'The service type `ros_name` implements, with the `srv` segment in the middle — `std_srvs/srv/Trigger`. A type whose request has no fields, like `Trigger`, needs neither `message` nor `parameters`: there is nothing to fill.',
|
|
1418
|
+
patternErrorMessage: ROS_TYPE_NAME_RULE,
|
|
1419
|
+
examples: ['std_srvs/srv/Trigger'],
|
|
1420
|
+
}),
|
|
1421
|
+
message: messageBody.optional(),
|
|
1422
|
+
parameters: parameterMap.optional(),
|
|
1423
|
+
description: serviceDescription.meta({
|
|
1424
|
+
description: 'What this service does, in the developer\'s own words. The robot does nothing with it — the readers are the console and MCP clients, and without one the service is still offered, with `description: null`, exactly as for an action. It sits on the configuration rather than on the app, so one wording is true for every app that reaches this robot.',
|
|
1425
|
+
examples: ['Resets odometry to the origin.'],
|
|
1426
|
+
}),
|
|
1427
|
+
}).meta({
|
|
1428
|
+
/** The value position of one entry — `reset_odometry: ▮` under `services:`. */
|
|
1429
|
+
defaultSnippets: [SERVICE_SNIPPET],
|
|
1430
|
+
});
|
|
1431
|
+
/**
|
|
1432
|
+
* One publisher, authored once for the two positions it is offered from:
|
|
1433
|
+
* `publishers:` (under `underSlug`) and the value of one entry below it.
|
|
1434
|
+
*
|
|
1435
|
+
* This is the one skeleton in the file that carries every part of the format at
|
|
1436
|
+
* once — a fixed message with two holes, the parameters that declare them, and
|
|
1437
|
+
* the failsafe — because a publisher with any of them missing is a publisher
|
|
1438
|
+
* the format refuses. `failsafe` is required and its message may hold no
|
|
1439
|
+
* placeholder: it is sent with no caller left to fill one.
|
|
1440
|
+
*/
|
|
1441
|
+
const PUBLISHER_SNIPPET = {
|
|
1442
|
+
label: 'a publisher, with its parameters and its failsafe',
|
|
1443
|
+
description: 'A topic clients may send to: what is fixed, what a caller fills, and what the bridge sends by itself once the caller falls silent.',
|
|
1444
|
+
body: {
|
|
1445
|
+
topic: '${2:/cmd_vel}',
|
|
1446
|
+
type: '${3:geometry_msgs/msg/Twist}',
|
|
1447
|
+
message: {
|
|
1448
|
+
linear: { x: param('speed') },
|
|
1449
|
+
angular: { z: param('turn') },
|
|
1450
|
+
},
|
|
1451
|
+
parameters: {
|
|
1452
|
+
speed: { type: 'float64', min_value: -0.5, max_value: 0.5, default: 0 },
|
|
1453
|
+
turn: { type: 'float64', min_value: -0.5, max_value: 0.5, default: 0 },
|
|
1454
|
+
},
|
|
1455
|
+
failsafe: {
|
|
1456
|
+
timeout_ms: 500,
|
|
1457
|
+
message: {
|
|
1458
|
+
linear: { x: 0 },
|
|
1459
|
+
angular: { z: 0 },
|
|
1460
|
+
},
|
|
1461
|
+
},
|
|
1462
|
+
quiet_timeout_ms: 2000,
|
|
1463
|
+
description: '${4:Velocity command. If sending stops, the robot stops.}',
|
|
1464
|
+
},
|
|
1465
|
+
};
|
|
1466
|
+
/**
|
|
1467
|
+
* A topic clients may publish to.
|
|
1468
|
+
*
|
|
1469
|
+
* `failsafe` groups the deadline with the message it triggers, because the
|
|
1470
|
+
* deadline exists for nothing else. The message must hold no placeholder:
|
|
1471
|
+
* the bridge sends it with no caller present, so there would be nobody to
|
|
1472
|
+
* fill one.
|
|
1473
|
+
*
|
|
1474
|
+
* **What that check can and cannot see.** It refuses a placeholder written
|
|
1475
|
+
* into an inline failsafe body. It does not refuse
|
|
1476
|
+
* `failsafe: { message: '${anything}' }` — a string at a `message:` position
|
|
1477
|
+
* is a *reference to a shared message*, and whether that message holds a
|
|
1478
|
+
* placeholder is a question about another section of the document, which a
|
|
1479
|
+
* schema over one publisher cannot answer. So `failsafe_has_parameters` is
|
|
1480
|
+
* half here and half in the cloud, on purpose and by position rather than by
|
|
1481
|
+
* accident: the inline half is decidable here, the referenced half is one of
|
|
1482
|
+
* the name-resolution codes the file header assigns to the cloud.
|
|
1483
|
+
*
|
|
1484
|
+
* `quiet_timeout_ms` is unrelated — how long a publisher must be silent
|
|
1485
|
+
* before a *different* user may send.
|
|
1486
|
+
*/
|
|
1487
|
+
export const publisherConfig = strictObject({
|
|
1488
|
+
topic: rosName.meta({
|
|
1489
|
+
description: 'The ROS topic the message is published onto, as an absolute graph name. **No client ever names a topic**: a caller addresses this entry by its slug, so the topics an app can write to are exactly the ones written in this file.',
|
|
1490
|
+
patternErrorMessage: ROS_NAME_RULE,
|
|
1491
|
+
examples: ['/cmd_vel'],
|
|
1492
|
+
}),
|
|
1493
|
+
type: rosTypeName.meta({
|
|
1494
|
+
description: 'The message type of `topic`, spelled the way ROS 2 spells it, with the `msg` segment. It fixes the shape that `message` and `failsafe.message` must both fill, which is why one publisher carries one type and a second type needs a second publisher.',
|
|
1495
|
+
patternErrorMessage: ROS_TYPE_NAME_RULE,
|
|
1496
|
+
examples: ['geometry_msgs/msg/Twist'],
|
|
1497
|
+
}),
|
|
1498
|
+
message: messageBody,
|
|
1499
|
+
parameters: parameterMap.optional(),
|
|
1500
|
+
failsafe: strictObject({
|
|
1501
|
+
timeout_ms: z.number().int().positive().max(60_000).meta({
|
|
1502
|
+
description: 'How long the bridge waits for the client\'s next send before sending the failsafe message itself, in milliseconds. The deadline runs **on the robot**, so it still fires when the link to the cloud is what failed — which is the case it exists for.',
|
|
1503
|
+
examples: [500, 1000],
|
|
1504
|
+
}),
|
|
1505
|
+
message: messageBody.meta({
|
|
1506
|
+
description: 'What the bridge sends once `timeout_ms` runs out — for a drive command, a zero twist. It must be safe in **every** state, because it is sent precisely when nobody is watching any more, and it may hold no placeholder: there is no caller left to fill one.',
|
|
1507
|
+
}),
|
|
1508
|
+
})
|
|
1509
|
+
/**
|
|
1510
|
+
* The string case is the exemption, not an oversight — see the paragraph
|
|
1511
|
+
* above — so it is tested first, where it reads as one.
|
|
1512
|
+
*/
|
|
1513
|
+
.refine((f) => typeof f.message === 'string' || placeholderNames(f.message).size === 0, {
|
|
1514
|
+
message: 'the failsafe message must contain no placeholder: it is sent with no caller to fill one',
|
|
1515
|
+
path: ['message'],
|
|
1516
|
+
params: { code: 'failsafe_has_parameters' },
|
|
1517
|
+
})
|
|
1518
|
+
.meta({
|
|
1519
|
+
description: 'What the bridge sends **by itself** once a client stops sending, and how long it waits first. This is the format\'s safety story in one field: a client that crashes, loses its connection or whose operator closes the window does not leave a robot driving. The message may hold no placeholder, inline or through a shared message — there is nobody left to fill one.',
|
|
1520
|
+
/**
|
|
1521
|
+
* Both fields are required, so the body carries both: a `failsafe:` with
|
|
1522
|
+
* only one of them is a publisher the format refuses, and this is the
|
|
1523
|
+
* field where a document that does not publish is the least useful thing
|
|
1524
|
+
* to hand somebody.
|
|
1525
|
+
*
|
|
1526
|
+
* The zero twist is the message this field's own description names, and
|
|
1527
|
+
* the same body the composite `publishers` snippet inserts. Neither
|
|
1528
|
+
* knows the publisher's ROS type — nothing at this position does — so
|
|
1529
|
+
* the snippet offers the format's canonical safe message rather than
|
|
1530
|
+
* guessing a shape. Every value in it is a literal: a placeholder here
|
|
1531
|
+
* is refused outright (`failsafe_has_parameters`), because the message
|
|
1532
|
+
* is sent with no caller left to fill one.
|
|
1533
|
+
*/
|
|
1534
|
+
defaultSnippets: [{
|
|
1535
|
+
label: 'a deadline, and the message it sends',
|
|
1536
|
+
description: 'Half a second of silence and then a zero twist. The deadline runs on the robot, so it still fires when the link to the cloud is what failed — which is the case it exists for.',
|
|
1537
|
+
body: {
|
|
1538
|
+
timeout_ms: 500,
|
|
1539
|
+
message: {
|
|
1540
|
+
linear: { x: 0 },
|
|
1541
|
+
angular: { z: 0 },
|
|
1542
|
+
},
|
|
1543
|
+
},
|
|
1544
|
+
}],
|
|
1545
|
+
}),
|
|
1546
|
+
quiet_timeout_ms: z.number().int().nonnegative().max(600_000).meta({
|
|
1547
|
+
description: 'How long this publisher must stay silent before a **different** user may send to it. Whoever sends holds it implicitly exclusive, with no session and no lock, so this one number is the whole handover policy: too short and two operators fight over one robot, too long and a crashed client blocks it for everyone.',
|
|
1548
|
+
examples: [2000],
|
|
1549
|
+
}),
|
|
1550
|
+
description: serviceDescription.meta({
|
|
1551
|
+
description: 'What sending to this publisher does, in the developer\'s own words. It is documentation for the console and for MCP clients — the robot does nothing with it — and as for actions and services, the publisher is offered whether or not one is written, with `description: null` when it is not. A caller sends here repeatedly and continuously rather than once, which is why this kind alone carries `failsafe` and `quiet_timeout_ms`.',
|
|
1552
|
+
examples: ['Velocity command. If sending stops, the robot stops.'],
|
|
1553
|
+
}),
|
|
1554
|
+
}).meta({
|
|
1555
|
+
/** The value position of one entry — `drive: ▮` under `publishers:`. */
|
|
1556
|
+
defaultSnippets: [PUBLISHER_SNIPPET],
|
|
1557
|
+
});
|
|
1558
|
+
/**
|
|
1559
|
+
* Camera credentials, in the document. There is no separate store any more.
|
|
1560
|
+
*
|
|
1561
|
+
* This was decided against a recorded objection, and the objection stands: a
|
|
1562
|
+
* password here is in every published version, and those are immutable. It
|
|
1563
|
+
* cannot be removed from history and cannot be rotated without republishing.
|
|
1564
|
+
* The bound on that decision is elsewhere and load-bearing — the publish
|
|
1565
|
+
* audit event and the org event stream must not carry the document body.
|
|
1566
|
+
*/
|
|
1567
|
+
export const cameraCredentials = strictObject({
|
|
1568
|
+
username: z.string().min(1).max(128).optional().meta({
|
|
1569
|
+
description: 'The account name the camera expects. For MJPEG the bridge sends a real HTTP `Authorization: Basic` header and leaves the URL untouched. RTSP offers no such channel through ffmpeg, so there the name goes inside the connect URL instead — built fresh for that one call and never written back into the stored document.',
|
|
1570
|
+
examples: ['ops'],
|
|
1571
|
+
}),
|
|
1572
|
+
password: z.string().min(1).max(128).optional().meta({
|
|
1573
|
+
description: 'The password for `username`. **There is no secret store behind this**: the value written here is the value stored, so treat it as readable by everyone who may read this robot\'s configuration, now and in its history.',
|
|
1574
|
+
}),
|
|
1575
|
+
})
|
|
1576
|
+
.meta({
|
|
1577
|
+
description: 'Username and password for the stream, standing **in clear text in the document**. A published version is immutable, so a password here cannot be removed from history or rotated without republishing — which is why the publish audit event carries only the version number and never the document body. Userinfo in the `url` works too; an explicit block here wins over it.',
|
|
1578
|
+
defaultSnippets: [{
|
|
1579
|
+
label: 'username and password',
|
|
1580
|
+
description: 'Both fields, in clear text — which is what this block is. The password default is deliberately not a password: `CHANGE-ME` is stored like any other value, but it is **visible** rather than plausible, so a reviewer reading the diff sees it and the camera rejects it at connect time — where a default that looked like a password would simply be published and kept.',
|
|
1581
|
+
/**
|
|
1582
|
+
* `CHANGE-ME`, and not a plausible-looking password, because of what the
|
|
1583
|
+
* comment above this schema records: a published version is immutable,
|
|
1584
|
+
* so a password written here cannot be removed from history or rotated
|
|
1585
|
+
* without republishing. This snippet is the one thing in the file that
|
|
1586
|
+
* could manufacture such a version by itself — a developer who tabs past
|
|
1587
|
+
* the placeholder publishes whatever the default was.
|
|
1588
|
+
*
|
|
1589
|
+
* What `CHANGE-ME` buys is **visibility, not a refusal**. It is stored
|
|
1590
|
+
* exactly like any other value; nothing at publish time objects. What it
|
|
1591
|
+
* does is fail at the camera, at connect time, and read wrong to anyone
|
|
1592
|
+
* looking at the diff — where a plausible default is published and kept.
|
|
1593
|
+
*
|
|
1594
|
+
* The two alternatives were both worse. A plausible default (`secret`)
|
|
1595
|
+
* reads in a diff like a value somebody chose, so nobody looks twice. A
|
|
1596
|
+
* bare `$2` inserts the empty string, which `min(1)` refuses — that is
|
|
1597
|
+
* loud, but it makes this the only snippet in the format that knowingly
|
|
1598
|
+
* inserts an invalid document, and the guard that says none of them do
|
|
1599
|
+
* would need an exception carved for it. A guard with an exception is not
|
|
1600
|
+
* a guard. So the default stays valid and stays obviously wrong: no
|
|
1601
|
+
* camera accepts it, and no reviewer reads past it.
|
|
1602
|
+
*
|
|
1603
|
+
* Both fields are `.optional()` — a bare `{}` parses — so nothing forces
|
|
1604
|
+
* a default here at all. It is offered because a developer who opened
|
|
1605
|
+
* this block wants both fields, and the snippet exists to save them the
|
|
1606
|
+
* typing, not to decide anything.
|
|
1607
|
+
*/
|
|
1608
|
+
body: {
|
|
1609
|
+
username: '${1:ops}',
|
|
1610
|
+
password: '${2:CHANGE-ME}',
|
|
1611
|
+
},
|
|
1612
|
+
}],
|
|
1613
|
+
});
|
|
1614
|
+
/**
|
|
1615
|
+
* Where a camera's frames come from (spec §10 names four sources).
|
|
1616
|
+
*
|
|
1617
|
+
* A discriminated union rather than optional fields, so an impossible camera
|
|
1618
|
+
* is **unrepresentable** rather than merely invalid — there is no way to
|
|
1619
|
+
* write an RTSP camera with a ROS topic, or a V4L2 device with a URL, and
|
|
1620
|
+
* therefore no validation rule to forget.
|
|
1621
|
+
*
|
|
1622
|
+
* **Each branch carries its own `defaultSnippets`, rather than one list on the
|
|
1623
|
+
* union.** Both placements were measured and both work; this one keeps a
|
|
1624
|
+
* label beside the branch it names, so the two cannot drift, and it makes a
|
|
1625
|
+
* fifth source impossible to add without one — `config-snippets.test.ts`
|
|
1626
|
+
* walks the exported branches and fails on any that carries none.
|
|
1627
|
+
*/
|
|
1628
|
+
/**
|
|
1629
|
+
* Named for the same reason as `chartStyle`: `describeValues` needs `.options`.
|
|
1630
|
+
*/
|
|
1631
|
+
const rtspTransport = z.enum(['tcp', 'udp']);
|
|
1632
|
+
export const cameraSource = z.discriminatedUnion('kind', [
|
|
1633
|
+
strictObject({
|
|
1634
|
+
kind: z.literal('ros').meta({
|
|
1635
|
+
description: 'Selects the ROS image-topic source: this camera then carries `topic` and `type`, and no field of another kind.',
|
|
1636
|
+
}),
|
|
1637
|
+
topic: rosName.meta({
|
|
1638
|
+
description: 'The ROS image topic the bridge subscribes to, as an absolute graph name. Clients never name it — they address the camera by its slug — so the topic can be renamed on the robot without an app changing.',
|
|
1639
|
+
patternErrorMessage: ROS_NAME_RULE,
|
|
1640
|
+
examples: ['/camera/image_raw'],
|
|
1641
|
+
}),
|
|
1642
|
+
type: rosTypeName.meta({
|
|
1643
|
+
description: 'The message type of `topic`: `sensor_msgs/msg/Image` for raw frames, `sensor_msgs/msg/CompressedImage` for a camera that already encodes. Declared here rather than introspected, so a camera can be configured for a robot that has never connected.',
|
|
1644
|
+
patternErrorMessage: ROS_TYPE_NAME_RULE,
|
|
1645
|
+
examples: ['sensor_msgs/msg/Image'],
|
|
1646
|
+
}),
|
|
1647
|
+
}).meta({
|
|
1648
|
+
description: 'Frames come from an image topic the robot already publishes. It is the only source the bridge **subscribes** to rather than opens, so it needs no URL, no device and nobody to authenticate to.',
|
|
1649
|
+
defaultSnippets: [{
|
|
1650
|
+
label: 'ros — an image topic the robot already publishes',
|
|
1651
|
+
description: 'Subscribes to a topic that is already there; nothing is opened and there is nobody to authenticate to.',
|
|
1652
|
+
/**
|
|
1653
|
+
* `type` is a **choice**, not a literal, and that is a correction: it was
|
|
1654
|
+
* written out on the rule that a field the format fixes is written out,
|
|
1655
|
+
* and `type` is not such a field. Its own description names two values
|
|
1656
|
+
* and says which applies when — `Image` for raw frames,
|
|
1657
|
+
* `CompressedImage` for a camera that encodes itself. A snippet that
|
|
1658
|
+
* picks one picks wrong for half the cameras, and picks it invisibly:
|
|
1659
|
+
* `rosTypeName` accepts either, publish accepts either, no diagnostic
|
|
1660
|
+
* fires anywhere, and the bridge then subscribes with the wrong type and
|
|
1661
|
+
* delivers no frames. A snippet supplying a wrong answer where it could
|
|
1662
|
+
* have supplied a question is this project's *check that cannot fire*,
|
|
1663
|
+
* arriving through a hint the developer trusts.
|
|
1664
|
+
*
|
|
1665
|
+
* `kind: 'ros'` stays a literal, because the branch really does fix it.
|
|
1666
|
+
*
|
|
1667
|
+
* Measured through the actual pipeline rather than assumed, because
|
|
1668
|
+
* choice syntax is the one construct here that three layers must each
|
|
1669
|
+
* pass through unharmed: yaml-language-server's `stringifyObject` emits
|
|
1670
|
+
* the body verbatim, and monaco-editor 0.52.2's `SnippetParser` parses
|
|
1671
|
+
* `${2|a,b|}` into a placeholder carrying both options whose
|
|
1672
|
+
* `toString()` — the text on the buffer before anyone chooses — is the
|
|
1673
|
+
* first one. So a developer who tabs past this gets a document
|
|
1674
|
+
* byte-identical to the literal it replaced, and one who opens the
|
|
1675
|
+
* picker gets `CompressedImage`; both parse.
|
|
1676
|
+
*/
|
|
1677
|
+
body: {
|
|
1678
|
+
kind: 'ros',
|
|
1679
|
+
topic: '${1:/camera/image_raw}',
|
|
1680
|
+
type: '${2|sensor_msgs/msg/Image,sensor_msgs/msg/CompressedImage|}',
|
|
1681
|
+
},
|
|
1682
|
+
}],
|
|
1683
|
+
}),
|
|
1684
|
+
strictObject({
|
|
1685
|
+
kind: z.literal('rtsp').meta({
|
|
1686
|
+
description: 'Selects the RTSP source: this camera then carries `url`, and optionally `transport` and `credentials`.',
|
|
1687
|
+
}),
|
|
1688
|
+
/**
|
|
1689
|
+
* Scheme-constrained deliberately. The playbook drafted `z.string().url()`
|
|
1690
|
+
* here and the shipped contract was `z.string().min(1).max(2048)` — nobody
|
|
1691
|
+
* recorded the change, and the W6 review found the consequence: the bridge
|
|
1692
|
+
* opens these with libraries that honour `file:` and `ftp:`, so an
|
|
1693
|
+
* unconstrained URL turns a configuration document into an arbitrary
|
|
1694
|
+
* local-file read on the robot, with the two distinct failure codes
|
|
1695
|
+
* doubling as a file-existence oracle. Spec §7.6 is ROS-pure exposure with
|
|
1696
|
+
* no shell or http features; that rule came back by omission rather than
|
|
1697
|
+
* by intent. The bridge re-checks this too — a robot must not become a
|
|
1698
|
+
* file server because a validator changed.
|
|
1699
|
+
*/
|
|
1700
|
+
url: z
|
|
1701
|
+
.string()
|
|
1702
|
+
.min(1)
|
|
1703
|
+
.max(2048)
|
|
1704
|
+
.regex(/^rtsps?:\/\//i, RTSP_URL_RULE)
|
|
1705
|
+
.meta({
|
|
1706
|
+
description: 'Where the stream lives, reached from the robot rather than from the cloud. **`rtsp://` or `rtsps://` only** — the bridge opens this with a library that would equally honour `file:`, so an unconstrained URL would turn a configuration document into arbitrary file access on the robot. The bridge re-checks the scheme itself, so a validator that changed could not make a robot serve files.',
|
|
1707
|
+
patternErrorMessage: RTSP_URL_RULE,
|
|
1708
|
+
examples: ['rtsp://cam-1.plant.local/stream1'],
|
|
1709
|
+
}),
|
|
1710
|
+
/** TCP by default: UDP loses frames on a congested link, silently. */
|
|
1711
|
+
transport: rtspTransport.optional().meta({
|
|
1712
|
+
description: 'How the RTSP payload is carried. Omitted means `tcp`: `udp` loses frames on a congested link and loses them silently, so the result looks like a failing camera rather than like a choice made here.',
|
|
1713
|
+
enumDescriptions: describeValues(rtspTransport.options, {
|
|
1714
|
+
tcp: 'The frames are interleaved into the RTSP connection itself, which is what a congested or lossy link needs — nothing is dropped on the way. This is what an omitted `transport` means.',
|
|
1715
|
+
udp: 'The frames travel in their own UDP stream: lower latency on a quiet network, and silent frame loss on any other.',
|
|
1716
|
+
}),
|
|
1717
|
+
}),
|
|
1718
|
+
credentials: cameraCredentials.optional(),
|
|
1719
|
+
}).meta({
|
|
1720
|
+
description: 'Frames come from an RTSP stream the robot itself can reach — a network camera on its own LAN. The bridge opens the connection; the cloud never does, and never needs a route to the camera.',
|
|
1721
|
+
defaultSnippets: [{
|
|
1722
|
+
label: 'rtsp — a network camera the robot itself can reach',
|
|
1723
|
+
description: 'A stream the bridge opens over RTSP. The scheme is written out because the format constrains it; the host and the path are what vary.',
|
|
1724
|
+
body: {
|
|
1725
|
+
kind: 'rtsp',
|
|
1726
|
+
url: 'rtsp://${1:cam-1.plant.local}/${2:stream1}',
|
|
1727
|
+
},
|
|
1728
|
+
}],
|
|
1729
|
+
}),
|
|
1730
|
+
strictObject({
|
|
1731
|
+
kind: z.literal('mjpeg').meta({
|
|
1732
|
+
description: 'Selects the MJPEG-over-HTTP source: this camera then carries `url`, and optionally `credentials`.',
|
|
1733
|
+
}),
|
|
1734
|
+
/** `http:`/`https:` only — see the `rtsp` variant above for why. */
|
|
1735
|
+
url: z
|
|
1736
|
+
.string()
|
|
1737
|
+
.min(1)
|
|
1738
|
+
.max(2048)
|
|
1739
|
+
.regex(/^https?:\/\//i, MJPEG_URL_RULE)
|
|
1740
|
+
.meta({
|
|
1741
|
+
description: 'Where the stream lives. **`http://` or `https://` only** — as for the `rtsp` URL, the bridge opens it with a library that would also serve `file:`. Plain `http://` is permitted because these cameras usually sit on the robot\'s own network, but Basic credentials on such a URL then travel in the clear.',
|
|
1742
|
+
patternErrorMessage: MJPEG_URL_RULE,
|
|
1743
|
+
/**
|
|
1744
|
+
* The host and the path this branch's own snippet body inserts, and the
|
|
1745
|
+
* URL its rule sentence names — one answer to "what goes here?", not a
|
|
1746
|
+
* third. The sibling `rtsp` url had an `examples` from the first day and
|
|
1747
|
+
* this position was the format's only silent URL (§1.1).
|
|
1748
|
+
*/
|
|
1749
|
+
examples: ['http://cam-1.plant.local/video.mjpg'],
|
|
1750
|
+
}),
|
|
1751
|
+
credentials: cameraCredentials.optional(),
|
|
1752
|
+
}).meta({
|
|
1753
|
+
description: 'Frames come from an MJPEG stream over HTTP — one JPEG after another, the simplest network source there is. Unlike `rtsp` there is no `transport` to choose: it is HTTP, and any `credentials` therefore travel as HTTP Basic.',
|
|
1754
|
+
defaultSnippets: [{
|
|
1755
|
+
label: 'mjpeg — one JPEG after another over HTTP',
|
|
1756
|
+
description: 'The simplest network source there is. `https://` is accepted too, and is what any credentials on this URL need.',
|
|
1757
|
+
body: {
|
|
1758
|
+
kind: 'mjpeg',
|
|
1759
|
+
url: 'http://${1:cam-1.plant.local}/${2:video.mjpg}',
|
|
1760
|
+
},
|
|
1761
|
+
}],
|
|
1762
|
+
}),
|
|
1763
|
+
strictObject({
|
|
1764
|
+
kind: z.literal('v4l2').meta({
|
|
1765
|
+
description: 'Selects the local capture-device source: this camera then carries `device` and nothing else.',
|
|
1766
|
+
}),
|
|
1767
|
+
/**
|
|
1768
|
+
* e.g. `/dev/video0`, or a stable `/dev/v4l/by-id/...` symlink. Resolved
|
|
1769
|
+
* on the robot, never by the cloud.
|
|
1770
|
+
*
|
|
1771
|
+
* Constrained to `/dev/` for the same reason the `rtsp` and `mjpeg` URLs
|
|
1772
|
+
* are constrained to their schemes, and it was missed the first time
|
|
1773
|
+
* (Momus, W6 verification). The device string reaches
|
|
1774
|
+
* `cv2.VideoCapture(device)` on the robot, and OpenCV does not restrict
|
|
1775
|
+
* itself to devices: measured on cv2 4.5.4, an ordinary local video file
|
|
1776
|
+
* opens and its pixels are published to the cloud, and so does
|
|
1777
|
+
* `http://127.0.0.1:8899/secret.jpg`. Unconstrained, this field is an
|
|
1778
|
+
* arbitrary local-file read *and* an outbound fetch from inside the robot
|
|
1779
|
+
* — the §7.6 violation closed for the other two source kinds, reachable
|
|
1780
|
+
* through the fourth, because "it is just a device path" read like a
|
|
1781
|
+
* reason not to check.
|
|
1782
|
+
*
|
|
1783
|
+
* Narrower than the URL hole in one respect worth recording: a non-media
|
|
1784
|
+
* file and a missing file both fail to open, so this branch never worked
|
|
1785
|
+
* as a file-existence oracle.
|
|
1786
|
+
*
|
|
1787
|
+
* The bridge re-derives this constraint rather than trusting the wire
|
|
1788
|
+
* (`validate_device_path`), exactly as it re-derives the URL scheme.
|
|
1789
|
+
*/
|
|
1790
|
+
device: z
|
|
1791
|
+
.string()
|
|
1792
|
+
.min(1)
|
|
1793
|
+
.max(128)
|
|
1794
|
+
.regex(/^\/dev\/[A-Za-z0-9][A-Za-z0-9._/-]*$/, DEVICE_PATH_RULE)
|
|
1795
|
+
.refine((v) => !v.split('/').includes('..'), 'must not contain a `..` path segment')
|
|
1796
|
+
.refine((v) => !v.endsWith('/'), 'must name a device, not a directory')
|
|
1797
|
+
.meta({
|
|
1798
|
+
description: 'The capture device, resolved on the robot and never by the cloud; a `/dev/v4l/by-id/...` symlink survives a reboot that renumbers `/dev/video0`. **Constrained to `/dev/`** — the string reaches OpenCV, which will just as happily open an ordinary video file or an `http://` URL and publish its pixels to the cloud. The bridge re-derives the same constraint rather than trusting the wire.',
|
|
1799
|
+
patternErrorMessage: DEVICE_PATH_RULE,
|
|
1800
|
+
examples: ['/dev/video0'],
|
|
1801
|
+
}),
|
|
1802
|
+
}).meta({
|
|
1803
|
+
description: 'Frames come from a capture device attached to the robot itself, such as a USB camera on `/dev/video0`. Nothing leaves the robot to fetch them, and there is nothing to authenticate to, so this source takes no `credentials`.',
|
|
1804
|
+
defaultSnippets: [{
|
|
1805
|
+
label: 'v4l2 — a capture device attached to the robot',
|
|
1806
|
+
description: 'A USB camera on the robot itself. A `/dev/v4l/by-id/...` symlink survives a reboot that renumbers `/dev/video0`.',
|
|
1807
|
+
/**
|
|
1808
|
+
* The default is not decoration. `device` is required and the path is
|
|
1809
|
+
* constrained to `/dev/`, so a bare `$1` would insert the empty string
|
|
1810
|
+
* and offer a camera the format refuses.
|
|
1811
|
+
*/
|
|
1812
|
+
body: {
|
|
1813
|
+
kind: 'v4l2',
|
|
1814
|
+
device: '${1:/dev/video0}',
|
|
1815
|
+
},
|
|
1816
|
+
}],
|
|
1817
|
+
}),
|
|
1818
|
+
]);
|
|
1819
|
+
/**
|
|
1820
|
+
* How often a snapshot is captured, in seconds. Bounded below at one second
|
|
1821
|
+
* because a snapshot is the *cheap* mode — a developer who wants motion wants
|
|
1822
|
+
* live, and an interval faster than this is a live stream wearing a disguise.
|
|
1823
|
+
*
|
|
1824
|
+
* The bound lives here once, and `rest.ts`'s `cameraDescriptor` reuses it —
|
|
1825
|
+
* the same treatment `rateThrottleHz` got, and for the same reason: the
|
|
1826
|
+
* descriptor used to say `snapshot_interval_ms` while the document said
|
|
1827
|
+
* seconds, so the cloud converted on one descriptor and not its sibling, with
|
|
1828
|
+
* nothing in either file saying so.
|
|
1829
|
+
*/
|
|
1830
|
+
export const snapshotIntervalSeconds = z.number().int().min(1).max(3600);
|
|
1831
|
+
/**
|
|
1832
|
+
* One camera, authored once for the two positions it is offered from:
|
|
1833
|
+
* `cameras:` (under `underSlug`) and the value of one entry below it.
|
|
1834
|
+
*
|
|
1835
|
+
* Every field of `cameraConfig` except `description` is required, so the body
|
|
1836
|
+
* carries all of them; the four `source` kinds each offer their own skeleton at
|
|
1837
|
+
* `source:` itself, and `v4l2` is the one here because a device path is the
|
|
1838
|
+
* only source a robot can be assumed to have without a network.
|
|
1839
|
+
*/
|
|
1840
|
+
const CAMERA_SNIPPET = {
|
|
1841
|
+
label: 'a camera',
|
|
1842
|
+
description: 'A complete camera entry with every required field.',
|
|
1843
|
+
body: {
|
|
1844
|
+
source: { kind: 'v4l2', device: '${2:/dev/video0}' },
|
|
1845
|
+
width: 1280,
|
|
1846
|
+
height: 720,
|
|
1847
|
+
fps: 15,
|
|
1848
|
+
bitrate_kbps: 2000,
|
|
1849
|
+
snapshot_interval_seconds: 5,
|
|
1850
|
+
description: '${3:Forward-facing camera on the mast.}',
|
|
1851
|
+
},
|
|
1852
|
+
};
|
|
1853
|
+
/**
|
|
1854
|
+
* A camera the robot exposes (spec §10).
|
|
1855
|
+
*
|
|
1856
|
+
* `width`/`height`/`fps`/`bitrate_kbps` are not cosmetic: §10 makes them the
|
|
1857
|
+
* developer's control over **the robot's own bandwidth**, which is why they
|
|
1858
|
+
* live in the configuration rather than in a viewer's request. A viewer never
|
|
1859
|
+
* gets to make a robot send more.
|
|
1860
|
+
*
|
|
1861
|
+
* The two modes are deliberately independent (§10):
|
|
1862
|
+
*
|
|
1863
|
+
* - **Snapshot** runs always, at `snapshot_interval_seconds`, whether or not
|
|
1864
|
+
* anyone is watching live. The cloud caches the one frame and serves every
|
|
1865
|
+
* client from it, so a hundred pollers cost the robot exactly one image per
|
|
1866
|
+
* interval.
|
|
1867
|
+
* - **Live** runs on demand and is refcounted in the cloud: the first viewer
|
|
1868
|
+
* starts it, the last one ends it.
|
|
1869
|
+
*/
|
|
1870
|
+
export const cameraConfig = strictObject({
|
|
1871
|
+
source: cameraSource.meta({
|
|
1872
|
+
description: 'Where this camera\'s frames come from. `kind` picks one of four sources and fixes which other fields the source may carry, so an impossible camera is unrepresentable rather than merely invalid — there is no way to write an RTSP camera with a ROS topic.',
|
|
1873
|
+
}),
|
|
1874
|
+
width: z.number().int().positive().max(7680).meta({
|
|
1875
|
+
description: 'The width the bridge scales frames to before sending, in pixels — what the bridge produces, not what the sensor captures; a snapshot can arrive narrower, since the bridge reduces both dimensions together to fit its JPEG byte ceiling. It stands in the configuration and never in a viewer\'s request, so no client can make the robot encode a larger frame than the developer allowed.',
|
|
1876
|
+
examples: [1280],
|
|
1877
|
+
}),
|
|
1878
|
+
height: z.number().int().positive().max(4320).meta({
|
|
1879
|
+
description: 'The height the bridge scales every frame to, in pixels; with `width` it is the size the live stream carries. A snapshot can arrive **smaller** than this — its JPEG has a byte ceiling, and the bridge gives up quality first and then resolution to fit, reporting the size it actually encoded.',
|
|
1880
|
+
examples: [720],
|
|
1881
|
+
}),
|
|
1882
|
+
fps: z.number().int().positive().max(60).meta({
|
|
1883
|
+
description: 'How many frames a second the bridge forwards, at most. It is a ceiling, not a clock: a camera that delivers ten frames a second stays at ten. Both modes read the same throttled pipeline, so this also bounds how fresh a snapshot can be.',
|
|
1884
|
+
examples: [15],
|
|
1885
|
+
}),
|
|
1886
|
+
bitrate_kbps: z.number().int().positive().max(50_000).meta({
|
|
1887
|
+
description: 'The ceiling for the **live** encoding, in kilobits per second — this is what bounds a watched camera against the robot\'s uplink. Snapshots are not covered by it: they are JPEGs under their own byte ceiling. Raising `width`, `height` or `fps` against a fixed bitrate buys blur, not detail.',
|
|
1888
|
+
examples: [2000],
|
|
1889
|
+
}),
|
|
1890
|
+
snapshot_interval_seconds: snapshotIntervalSeconds.meta({
|
|
1891
|
+
description: 'How often a still frame is captured, in seconds. **It runs whether or not anyone is watching**, unlike the live stream, which the cloud refcounts — first viewer starts it, last one ends it. The cloud caches the one frame and serves every reader from it, so a hundred pollers cost the robot exactly one image per interval.',
|
|
1892
|
+
examples: [5],
|
|
1893
|
+
}),
|
|
1894
|
+
description: serviceDescription.meta({
|
|
1895
|
+
description: 'What this camera shows, in the developer\'s own words — documentation for whoever reads the configuration, for the console and for MCP clients; the robot does nothing with it. A camera without one is still offered, with `description: null`, as for actions, services and publishers. What `camera_snapshot` serves is the latest snapshot with its age; a live session is never a tool.',
|
|
1896
|
+
examples: ['Forward-facing camera on the mast.'],
|
|
1897
|
+
}),
|
|
1898
|
+
}).meta({
|
|
1899
|
+
/** The value position of one entry — `front: ▮` under `cameras:`. */
|
|
1900
|
+
defaultSnippets: [CAMERA_SNIPPET],
|
|
1901
|
+
});
|
|
1902
|
+
/**
|
|
1903
|
+
* The format version of a `fleetless.yaml`. Deliberately not called
|
|
1904
|
+
* `version`: the console counts published states with "v12 → v13", and two
|
|
1905
|
+
* numbers called version would be the likeliest confusion in the format.
|
|
1906
|
+
*/
|
|
1907
|
+
export const FLEETLESS_FORMAT_VERSION = 1;
|
|
1908
|
+
const capped = (entry, max, what) => slugKeyed(entry).refine((m) => Object.keys(m).length <= max, { message: `at most ${max} ${what}` });
|
|
1909
|
+
/**
|
|
1910
|
+
* A whole robot configuration — everything configurable about one robot.
|
|
1911
|
+
*
|
|
1912
|
+
* Every section is a mapping keyed by name, not a list of objects carrying
|
|
1913
|
+
* their own name. A duplicate name is then a YAML syntax error rather than a
|
|
1914
|
+
* rule somebody has to write, and the name reads as the entry's heading.
|
|
1915
|
+
*
|
|
1916
|
+
* Slugs remain ONE namespace across all five exposure sections (§4.1), which
|
|
1917
|
+
* is what lets a role grant say `{robot, slug}` without naming a kind. That
|
|
1918
|
+
* check spans sections and therefore lives in the cloud, not here.
|
|
1919
|
+
*/
|
|
1920
|
+
export const robotConfigDoc = strictObject({
|
|
1921
|
+
fleetless: z.literal(FLEETLESS_FORMAT_VERSION).meta({
|
|
1922
|
+
description: 'The format version, and the first line of the file. It decides how everything below is read, so a file that omits it — or names a version this cloud does not know — is **refused rather than half understood**.',
|
|
1923
|
+
}),
|
|
1924
|
+
messages: messageMap.meta({
|
|
1925
|
+
description: 'Reusable message bodies, keyed by name, inserted elsewhere by writing `${name}` directly after `message:`. A shared body may hold placeholders and whoever inserts it declares the parameters, so two publishers can send the same message under different bounds. **A shared message may not insert another**, so a `${name}` inside a body is always a parameter and never a second message.',
|
|
1926
|
+
defaultSnippets: [underSlug('${1:drive}', SHARED_MESSAGE_SNIPPET)],
|
|
1927
|
+
}).optional(),
|
|
1928
|
+
datapoints: capped(datapointConfig, 200, 'datapoints').meta({
|
|
1929
|
+
description: 'Values the robot publishes, each one field of one topic or a whole topic, and **never several topics**. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
|
|
1930
|
+
defaultSnippets: [
|
|
1931
|
+
underSlug('${1:battery_voltage}', DATAPOINT_SNIPPET),
|
|
1932
|
+
underSlug('${1:battery}', NUMERIC_DATAPOINT_SNIPPET),
|
|
1933
|
+
],
|
|
1934
|
+
}).optional(),
|
|
1935
|
+
actions: capped(actionConfig, 200, 'actions').meta({
|
|
1936
|
+
description: 'Things the robot does on request that take time, each reported as a job with progress. **At most one job runs per action slug**: a second call is refused `busy`, and every observer of that slug watches the same job. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
|
|
1937
|
+
defaultSnippets: [underSlug('${1:navigate}', ACTION_SNIPPET)],
|
|
1938
|
+
}).optional(),
|
|
1939
|
+
services: capped(serviceConfig, 200, 'services').meta({
|
|
1940
|
+
description: 'ROS service calls the robot answers — one request, one reply. Unlike an action a service reports **no progress** and the call returns with its result already on the job, so there is nothing left to observe; a second concurrent call is still refused `busy`, exactly as for an action. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
|
|
1941
|
+
defaultSnippets: [underSlug('${1:reset_odometry}', SERVICE_SNIPPET)],
|
|
1942
|
+
}).optional(),
|
|
1943
|
+
publishers: capped(publisherConfig, 200, 'publishers').meta({
|
|
1944
|
+
description: 'Topics clients may send to, and where the format\'s whole safety story lives. The `message` template fixes every value a caller cannot change, and **`failsafe` is required**: once a client falls silent the bridge sends the failsafe message itself, so an operator whose window closed does not leave a robot driving. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
|
|
1945
|
+
defaultSnippets: [underSlug('${1:drive}', PUBLISHER_SNIPPET)],
|
|
1946
|
+
}).optional(),
|
|
1947
|
+
cameras: capped(cameraConfig, 50, 'cameras').meta({
|
|
1948
|
+
description: 'Video the robot streams, and the still frames the cloud serves from it. `width`, `height`, `fps` and `bitrate_kbps` are what **the bridge produces before sending**, not what the camera captures — they live in the configuration rather than in a viewer\'s request precisely so that no viewer can make a robot send more. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
|
|
1949
|
+
defaultSnippets: [underSlug('${1:front}', CAMERA_SNIPPET)],
|
|
1950
|
+
}).optional(),
|
|
1951
|
+
});
|
|
1952
|
+
/**
|
|
1953
|
+
* One thing the cloud has to say about a configuration (spec §11.5: field +
|
|
1954
|
+
* violated rule).
|
|
1955
|
+
*
|
|
1956
|
+
* `error` blocks the publish. `warning` does not — an unknown topic is a
|
|
1957
|
+
* warning on purpose, because configuring a robot that has never been
|
|
1958
|
+
* connected must stay possible (spec §4.1).
|
|
1959
|
+
*/
|
|
1960
|
+
export const validationIssue = z.object({
|
|
1961
|
+
path: z.string().min(1),
|
|
1962
|
+
slug: z.string().nullable(),
|
|
1963
|
+
code: z.string().min(1),
|
|
1964
|
+
message: z.string().min(1),
|
|
1965
|
+
severity: z.enum(['error', 'warning']),
|
|
1966
|
+
});
|
|
1967
|
+
/**
|
|
1968
|
+
* Where a robot's configuration stands — the material for the console's
|
|
1969
|
+
* "draft newer than published", "published v2 · applied v1 · bridge offline"
|
|
1970
|
+
* (spec §15.2, robot tab 1).
|
|
1971
|
+
*/
|
|
1972
|
+
export const configState = z.object({
|
|
1973
|
+
published_version: z.number().int().positive().nullable(),
|
|
1974
|
+
published_at: z.iso.datetime().nullable(),
|
|
1975
|
+
draft_updated_at: z.iso.datetime().nullable(),
|
|
1976
|
+
applied_version: z.number().int().nonnegative().nullable(),
|
|
1977
|
+
applied_ok: z.boolean().nullable(),
|
|
1978
|
+
/**
|
|
1979
|
+
* The bridge's own `bridgeConfigApplied.errors` (`protocol.ts`), read back
|
|
1980
|
+
* verbatim. **Reuses `applyError` rather than restating `{ slug, message
|
|
1981
|
+
* }`** — a narrower local copy here used to silently strip `kind`, `code`
|
|
1982
|
+
* and `details` on every read: `configState.safeParse` dropped every field
|
|
1983
|
+
* a caller did not ask for, and `robotDetailResponse` embeds `configState`
|
|
1984
|
+
* (`useCloudApi.ts`'s `getRobot`), so the console lost the fields one
|
|
1985
|
+
* layer before anyone could see them.
|
|
1986
|
+
*/
|
|
1987
|
+
applied_errors: z.array(applyError).nullable(),
|
|
1988
|
+
});
|