diff --git a/README-dev.md b/README-dev.md index e0eb642a..a412085c 100644 --- a/README-dev.md +++ b/README-dev.md @@ -67,6 +67,7 @@ The following components are run by the previous scripts: | Ingest | Ingest service (`./run.sh` in `/ingest`); connects to MQTT and MongoDB | — | | ISO 21423 (optional) | ISO-native robots join the fleet via ingest, disabled by default — see `docs/iso21423-robots.md` | — | | Floor plans (optional) | Draw robots on a pretty map: `SpatialAnnotation` + `SpatialTransformation`, fitted with `tools/align_maps.py` — see `docs/map-alignment.md` | — | +| Ghost robots (optional) | Static demo robots, online with datasources and a pose but no live data: the `Robot` config kind — see `docs/ghost-robots.md` | — | ## Instance initial setup diff --git a/app/imports/server/configAPI/configAPI.js b/app/imports/server/configAPI/configAPI.js index 0311f8be..1b990894 100644 --- a/app/imports/server/configAPI/configAPI.js +++ b/app/imports/server/configAPI/configAPI.js @@ -30,6 +30,7 @@ import DashboardsConfigAPIHandler from './dashboards'; import StatusConfigAPIHandler from './statusDefinitions'; import ModuleStateConfigAPIHandler from './moduleState'; import IsoRobotConfigAPIHandler from './isoRobots'; +import RobotConfigAPIHandler from './robots'; import SpatialAnnotationConfigAPIHandler from './spatialAnnotation'; // import MissionTrackingAPIHandler from './missionTracking'; import RobotFootprintConfigAPIHandler from './robotFootprint'; @@ -58,6 +59,7 @@ import { KIND_DASHBOARD_DEFINITION, KIND_MODULE_STATE, KIND_ISO_ROBOT, + KIND_ROBOT, KIND_SPATIAL_ANNOTATION, KIND_SPATIAL_TRANSFORMATION, } from '../../shared/configAPI'; @@ -116,6 +118,7 @@ export default class ConfigAPI { [KIND_DASHBOARD_DEFINITION]: new DashboardsConfigAPIHandler(this), [KIND_MODULE_STATE]: new ModuleStateConfigAPIHandler(this), [KIND_ISO_ROBOT]: new IsoRobotConfigAPIHandler(this), + [KIND_ROBOT]: new RobotConfigAPIHandler(this), [KIND_SPATIAL_ANNOTATION]: new SpatialAnnotationConfigAPIHandler(this), [KIND_SPATIAL_TRANSFORMATION]: new SpatialTransformationConfigAPIHandler(this), // [KIND_MISSION_TRACKING]: new MissionTrackingAPIHandler(this), diff --git a/app/imports/server/configAPI/robots.js b/app/imports/server/configAPI/robots.js new file mode 100644 index 00000000..aaab8571 --- /dev/null +++ b/app/imports/server/configAPI/robots.js @@ -0,0 +1,185 @@ +/** + * Copyright 2026 InOrbit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * `Robot` configuration kind: declares a robot by id, with its identity fields (label). + * + * A real robot can be pre-registered this way before its agent ever connects; ingest owns + * status, pose and telemetry from then on, and re-applying only touches the identity fields. + * + * With `ghost: true` the robot is a static demo placeholder: it is marked with the platform's + * ghost sentinel (`updateStamp` / `ts` = HACK_GHOST_UPDATE_STAMP_VALUE, which the UI treats as + * "never stale") and the spec may also carry `pose`, `attributes` and `statuses`, written once + * into the same collections ingest would write for a live robot. Those fields are rejected on + * non-ghost robots: this kind is not a way to feed live data. + */ +import Validator from 'fastest-validator'; + +import Robot from '../model/robot'; +import OroRoles from '../roles'; +import { Robots, RobotLocalization, RobotKeyValues } from '../../lib/collections'; +import { AttrValues } from '../../lib/attributes'; +import { RobotStatus } from '../../lib/status'; +import { STATUS } from '../../shared/status'; +import { HACK_GHOST_UPDATE_STAMP_VALUE } from '../../shared/constants'; +import { VITAL_ONLINE } from '../../shared/attributes'; +import { + SchemaError, ValidationError, AuthorizationError, LIST_FORMAT_SHORT, KIND_ROBOT, +} from '../../shared/configAPI'; +import { RESOURCE_SINGLETONS, ACCESS_LEVEL_CONFIGURE, isSystemUser } from '../../shared/roles'; + +const GHOST_TS = HACK_GHOST_UPDATE_STAMP_VALUE; +const GHOST_VERSION = 'ghost'; +// Shown as the agent version until the real agent reports one +const UNKNOWN_VERSION = 'unknown'; +const STATIC_DATA_FIELDS = ['pose', 'attributes', 'statuses']; +const STATUS_NAMES = ['OK', 'WARN', 'ERROR']; + +// Same guard as isoRobots.js / moduleState.js: configure access on the fleet singleton. +const assertAuthorized = async (user) => { + if (!user) { + throw new AuthorizationError('Unauthorized'); + } + if (!isSystemUser(user) && !await new OroRoles().canAccessSystemElement( + user._id, + RESOURCE_SINGLETONS.FLEET, + ACCESS_LEVEL_CONFIGURE, + )) { + throw new AuthorizationError('Unauthorized'); + } +}; + +const scalar = { type: 'multi', rules: ['string', 'number', 'boolean'] }; +const RobotSpecSchema = { + $$strict: true, + label: { type: 'string', optional: true, empty: false }, + ghost: { type: 'boolean', optional: true }, + pose: { + type: 'object', + optional: true, + strict: true, + props: { x: 'number', y: 'number', theta: { type: 'number', optional: true } }, + }, + attributes: { type: 'record', optional: true, value: scalar }, + statuses: { type: 'record', optional: true, value: { type: 'enum', values: STATUS_NAMES } }, +}; +const specValidator = new Validator().compile(RobotSpecSchema); + +const validateSpec = (spec) => { + const validation = specValidator(spec); + if (validation !== true) { + throw new SchemaError(`Robot spec is invalid: ${validation.map((v) => v.message).join('; ')}`); + } + const staticFields = STATIC_DATA_FIELDS.filter((f) => spec[f] !== undefined); + if (!spec.ghost && staticFields.length) { + throw new SchemaError( + `Robot spec fields ${staticFields.join(', ')} are only allowed on ghost robots (ghost: true)`); + } +}; + +const stamped = (values, wrap = (v) => ({ value: v })) => Object.fromEntries( + Object.entries(values).map(([k, v]) => [k, { ...wrap(v, k), ts: GHOST_TS }])); + +export class RobotConfigAPIHandler { + constructor(configApi) { + this._configApi = configApi; + } + + isGlobalConfig = () => false; + + list = async ({ id = null, user, format = LIST_FORMAT_SHORT } = {}) => { + await assertAuthorized(user); + const query = id ? { _id: id } : {}; + const docs = await Robots.find(query, { projection: { name: 1, updateStamp: 1 } }) + .fetchAsync(); + return docs.map((doc) => ({ + apiVersion: 'v0.1', + kind: KIND_ROBOT, + metadata: { id: doc._id, scope: '' }, + ...(format === LIST_FORMAT_SHORT ? {} : { + spec: { + label: doc.name, + ...(doc.updateStamp === GHOST_TS ? { ghost: true } : {}), + }, + }), + })); + }; + + apply = async ({ configObject, user }) => { + await assertAuthorized(user); + const robotId = configObject.metadata.id; + const spec = configObject.spec || {}; + validateSpec(spec); + + const existing = await Robots.findOneAsync({ _id: robotId }, { projection: { _id: 1 } }); + if (!existing) { + await Robot.createAsync({ + robotId, + name: spec.label || robotId, + agentVersion: spec.ghost ? GHOST_VERSION : UNKNOWN_VERSION, + }); + } + const $set = {}; + if (spec.label) $set.name = spec.label; + if (spec.ghost) { + Object.assign($set, { updateStamp: GHOST_TS, 'status.agentOnline': true, version: GHOST_VERSION }); + } + if (Object.keys($set).length) { + await Robots.updateAsync({ _id: robotId }, { $set }); + } + + if (spec.ghost) { + await this._writeGhostData(robotId, spec); + } + console.log(`Robot: applied ${spec.ghost ? 'ghost ' : ''}robot ${robotId}`); + }; + + // Static data a ghost carries, in the collections ingest writes for a live robot. + _writeGhostData = async (robotId, { pose, attributes = {}, statuses = {} }) => { + const upsert = (coll, $set) => coll.updateAsync({ _id: robotId }, { $set }, { upsert: true }); + await upsert(AttrValues, stamped({ ...attributes, [VITAL_ONLINE]: true })); + await upsert(RobotKeyValues, stamped(attributes)); + await upsert(RobotStatus, stamped(statuses, (s, attrId) => ({ name: attrId, value: STATUS[s].value }))); + if (pose) { + await upsert(RobotLocalization, { + robotPose: { x: pose.x, y: pose.y, theta: pose.theta || 0, ts: GHOST_TS }, + robotPoseUpdatedTs: GHOST_TS, + }); + } + }; + + clear = async ({ configObject, user }) => { + await assertAuthorized(user); + const robotId = configObject.metadata.id; + const doc = await Robots.findOneAsync({ _id: robotId }, { projection: { updateStamp: 1, status: 1 } }); + if (!doc) { + return; + } + const ghost = doc.updateStamp === GHOST_TS; + if (!ghost && doc.status?.agentOnline) { + throw new ValidationError(`Robot ${robotId} is online; it cannot be cleared`); + } + await Robots.removeAsync({ _id: robotId }); + if (ghost) { + for (const coll of [AttrValues, RobotKeyValues, RobotStatus, RobotLocalization]) { + await coll.removeAsync({ _id: robotId }); + } + } + console.log(`Robot: cleared ${ghost ? 'ghost ' : ''}robot ${robotId}`); + }; +} + +export default RobotConfigAPIHandler; diff --git a/app/imports/server/test/configAPI/configAPIRobot.test.js b/app/imports/server/test/configAPI/configAPIRobot.test.js new file mode 100644 index 00000000..9298a65c --- /dev/null +++ b/app/imports/server/test/configAPI/configAPIRobot.test.js @@ -0,0 +1,185 @@ +/** + * Copyright 2026 InOrbit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Configuration as code tests for the Robot kind (real robots and ghost robots) + */ +import { Meteor } from 'meteor/meteor'; +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +// ORO modules +import { resetDatabase } from '../setup'; +import ConfigAPI from '../../configAPI/configAPI'; +import OroRoles from '../../roles'; +import { + AuthorizationError, KIND_ROBOT, LIST_FORMAT_FULL, SchemaError, ValidationError, +} from '../../../shared/configAPI'; +import { ROLE_VIEWER, ROLE_MANAGER } from '../../../lib/roles'; +import { createUser } from '../configAPI'; +import { Robots, RobotLocalization, RobotKeyValues } from '../../../lib/collections'; +import { AttrValues } from '../../../lib/attributes'; +import { RobotStatus } from '../../../lib/status'; +import { STATUS } from '../../../shared/status'; +import { HACK_GHOST_UPDATE_STAMP_VALUE } from '../../../shared/constants'; + +// Just make sure this is not getting loaded in dev/prod +if (!Meteor.isTest) { + throw new Error('This is TEST code only'); +} +const { expect } = chai; +chai.use(chaiAsPromised); + +const GHOST_TS = HACK_GHOST_UPDATE_STAMP_VALUE; + +const robotObject = (id, spec) => ({ + kind: KIND_ROBOT, apiVersion: 'v0.1', metadata: { id }, spec, +}); + +const GHOST_SPEC = { + label: 'Picker 4', + ghost: true, + pose: { x: 4.2, y: 1.1, theta: 1.57 }, + attributes: { batteryPercentage: 0.71, missionStatus: 'idle' }, + statuses: { localization: 'OK', cpuLoadPercentage: 'WARN' }, +}; + +describe('configAPI:Robot kind', () => { + let configApi; + let manager; + + beforeEach(async () => { + await resetDatabase(); + configApi = await new ConfigAPI().init({}); + await new OroRoles().createDefaultRoles(); + manager = await createUser({ role: ROLE_MANAGER }); + }); + + after(resetDatabase); + + it('apply: rejects users without fleet configure permission', async () => { + const viewer = await createUser({ id: 'v1ewer', role: ROLE_VIEWER }); + await expect(configApi.apply({ configObject: robotObject('r1', { label: 'R1' }), user: viewer })) + .to.be.rejectedWith(AuthorizationError); + }); + + it('apply: creates an offline real robot with its name, and nothing else', async () => { + await configApi.apply({ configObject: robotObject('r1', { label: 'R1' }), user: manager }); + const doc = await Robots.findOneAsync({ _id: 'r1' }); + expect(doc.name).to.equal('R1'); + expect(doc.status.agentOnline).to.equal(false); + expect(doc.updateStamp).to.not.equal(GHOST_TS); + expect(await AttrValues.findOneAsync({ _id: 'r1' })).to.equal(undefined); + expect(await RobotLocalization.findOneAsync({ _id: 'r1' })).to.equal(undefined); + }); + + it('apply: defaults the name to the id', async () => { + await configApi.apply({ configObject: robotObject('r1', {}), user: manager }); + expect((await Robots.findOneAsync({ _id: 'r1' })).name).to.equal('r1'); + }); + + it('apply: re-applying an existing robot touches only the name', async () => { + await Robots.insertAsync({ + _id: 'r1', name: 'old', version: '4.15.1', status: { agentOnline: true }, updateStamp: 1000, + }); + await configApi.apply({ configObject: robotObject('r1', { label: 'new' }), user: manager }); + const doc = await Robots.findOneAsync({ _id: 'r1' }); + expect(doc).to.include({ name: 'new', version: '4.15.1', updateStamp: 1000 }); + expect(doc.status.agentOnline).to.equal(true); + }); + + it('apply: rejects static data on a non-ghost robot', async () => { + for (const extra of [{ pose: { x: 0, y: 0 } }, { attributes: { a: 1 } }, { statuses: { a: 'OK' } }]) { + await expect(configApi.apply({ configObject: robotObject('r1', { ...extra }), user: manager })) + .to.be.rejectedWith(SchemaError); + } + expect(await Robots.findOneAsync({ _id: 'r1' })).to.equal(undefined); + }); + + it('apply: rejects unknown spec fields and bad status values', async () => { + await expect(configApi.apply({ configObject: robotObject('r1', { nope: 1 }), user: manager })) + .to.be.rejectedWith(SchemaError); + await expect(configApi.apply({ + configObject: robotObject('r1', { ghost: true, statuses: { a: 'FINE' } }), user: manager, + })).to.be.rejectedWith(SchemaError); + }); + + it('apply: a ghost is online with the sentinel stamp and static data in every collection', async () => { + await configApi.apply({ configObject: robotObject('g1', GHOST_SPEC), user: manager }); + + const robot = await Robots.findOneAsync({ _id: 'g1' }); + expect(robot).to.include({ name: 'Picker 4', updateStamp: GHOST_TS }); + expect(robot.status.agentOnline).to.equal(true); + + const attrs = await AttrValues.findOneAsync({ _id: 'g1' }); + expect(attrs.batteryPercentage).to.deep.equal({ value: 0.71, ts: GHOST_TS }); + expect(attrs.missionStatus).to.deep.equal({ value: 'idle', ts: GHOST_TS }); + expect(attrs.agentOnline).to.deep.equal({ value: true, ts: GHOST_TS }); + + const kv = await RobotKeyValues.findOneAsync({ _id: 'g1' }); + expect(kv.batteryPercentage).to.deep.equal({ value: 0.71, ts: GHOST_TS }); + + const status = await RobotStatus.findOneAsync({ _id: 'g1' }); + expect(status.localization).to.deep.equal({ name: 'localization', value: STATUS.OK.value, ts: GHOST_TS }); + expect(status.cpuLoadPercentage.value).to.equal(STATUS.WARN.value); + + const loc = await RobotLocalization.findOneAsync({ _id: 'g1' }); + expect(loc.robotPose).to.deep.equal({ x: 4.2, y: 1.1, theta: 1.57, ts: GHOST_TS }); + expect(loc.robotPoseUpdatedTs).to.equal(GHOST_TS); + }); + + it('apply: re-applying a ghost replaces its static values', async () => { + await configApi.apply({ configObject: robotObject('g1', GHOST_SPEC), user: manager }); + await configApi.apply({ + configObject: robotObject('g1', { ...GHOST_SPEC, attributes: { batteryPercentage: 0.2 } }), user: manager, + }); + const attrs = await AttrValues.findOneAsync({ _id: 'g1' }); + expect(attrs.batteryPercentage.value).to.equal(0.2); + expect(await Robots.find({}).countAsync()).to.equal(1); + }); + + it('clear: removes a ghost and all of its static data', async () => { + await configApi.apply({ configObject: robotObject('g1', GHOST_SPEC), user: manager }); + await configApi.clear({ configObject: robotObject('g1'), user: manager }); + for (const coll of [Robots, AttrValues, RobotKeyValues, RobotStatus, RobotLocalization]) { + expect(await coll.findOneAsync({ _id: 'g1' })).to.equal(undefined); + } + }); + + it('clear: removes an offline real robot but refuses an online one', async () => { + await configApi.apply({ configObject: robotObject('r1', { label: 'R1' }), user: manager }); + await configApi.clear({ configObject: robotObject('r1'), user: manager }); + expect(await Robots.findOneAsync({ _id: 'r1' })).to.equal(undefined); + + await Robots.insertAsync({ + _id: 'r2', name: 'r2', version: '1', status: { agentOnline: true }, updateStamp: Date.now(), + }); + await expect(configApi.clear({ configObject: robotObject('r2'), user: manager })) + .to.be.rejectedWith(ValidationError); + expect(await Robots.findOneAsync({ _id: 'r2' })).to.not.equal(undefined); + }); + + it('list: full format returns re-applicable identity specs, flagging ghosts', async () => { + await configApi.apply({ configObject: robotObject('g1', GHOST_SPEC), user: manager }); + await configApi.apply({ configObject: robotObject('r1', { label: 'R1' }), user: manager }); + const list = await configApi.list({ user: manager, kind: KIND_ROBOT, format: LIST_FORMAT_FULL }); + const byId = Object.fromEntries(list.map((o) => [o.metadata.id, o])); + expect(Object.keys(byId).sort()).to.deep.equal(['g1', 'r1']); + expect(byId.r1.spec).to.deep.equal({ label: 'R1' }); + expect(byId.g1.spec).to.deep.equal({ label: 'Picker 4', ghost: true }); + const short = await configApi.list({ user: manager, kind: KIND_ROBOT }); + expect(short[0].spec).to.equal(undefined); + }); +}); diff --git a/app/imports/shared/configAPI.js b/app/imports/shared/configAPI.js index cd3858d6..9e584a48 100644 --- a/app/imports/shared/configAPI.js +++ b/app/imports/shared/configAPI.js @@ -91,6 +91,7 @@ const KIND_NOTIFICATION_CHANNEL_GOOGLE_CHAT = 'NotificationChannel.GoogleChat'; const KIND_TEST = 'Test'; const KIND_MODULE_STATE = 'ModuleState'; const KIND_ISO_ROBOT = 'IsoRobot'; +const KIND_ROBOT = 'Robot'; // This constant is the only valid element id to use in apply() or clear() config calls for // kinds that represent "singletons", which don't have individual elmeents to configure. @@ -296,6 +297,7 @@ export { KIND_TEST, KIND_MODULE_STATE, KIND_ISO_ROBOT, + KIND_ROBOT, keyValueObjectValidator, CONFIG_API_GLOBAL_ID, ALPHANUMERIC_PATTERN, diff --git a/app/tests/main.js b/app/tests/main.js index b42a74ce..77ac7c7f 100644 --- a/app/tests/main.js +++ b/app/tests/main.js @@ -42,6 +42,7 @@ import '../imports/server/test/configAPI/configAPISpatialAnnotation.test.js' import '../imports/server/test/configAPI/configAPISpatialTransformation.test.js' import '../imports/server/test/configAPI/configAPIRobotFootprint.test.js' import '../imports/server/test/configAPI/configAPIRobotPath.test.js' +import '../imports/server/test/configAPI/configAPIRobot.test.js' import '../imports/server/test/timeseries/timeseriesStore.test.js' import '../imports/server/test/timeseries/timeseriesManager.test.js' import '../imports/server/test/isoMqttConfig.test' diff --git a/docs/ghost-robots.md b/docs/ghost-robots.md new file mode 100644 index 00000000..556677fd --- /dev/null +++ b/docs/ghost-robots.md @@ -0,0 +1,50 @@ +# Robot config kind and ghost robots + +`Robot` is a configuration kind (`POST /api/configuration/apply`, like every other kind) that +declares a robot by id with its identity fields. It is **not** a way to feed live data: ingest owns +status, pose and telemetry, and this kind only ever sets a name on a real robot. + +```yaml +apiVersion: v0.1 +kind: Robot +metadata: { id: picker-3, scope: "" } +spec: + label: Picker 3 # optional, defaults to the id +``` + +Applying a real robot before its agent connects pre-registers it offline with that name; +re-applying touches only `label`. Clearing it deletes the robot document, and is +refused while the robot is online. + +## Ghost robots (demos) + +With `ghost: true` the robot is a static placeholder: it reads as **online**, and the spec may also +carry a pose, datasource values and statuses. These three fields are rejected without `ghost: true`. + +```yaml +apiVersion: v0.1 +kind: Robot +metadata: { id: picker-4, scope: "" } +spec: + label: Picker 4 + ghost: true + pose: { x: 8.0, y: 5.0, theta: 1.57 } # default map frame; theta optional + attributes: # datasource ids -> value (string/number/bool) + batteryPercentage: 0.55 + cpuLoadPercentage: 0.63 + missionStatus: running + statuses: { cpuLoadPercentage: WARN, localization: OK } # OK | WARN | ERROR +``` + +What it writes, once, using the platform's ghost sentinel (`updateStamp` and every `ts` set to +`HACK_GHOST_UPDATE_STAMP_VALUE`, which the UI treats as never stale): + +| Spec field | Collection | Shown by | +|--------------|---------------------|--------------------------------------------| +| (always) | `robots` | fleet list, online indicator | +| `attributes` | `attr_values`, `robot_key_values` | Vitals / Data Sources / Key Values widgets | +| `statuses` | `robot_status` | Fleet Status chips | +| `pose` | `localization` | Navigation widget and fleet map | + +Percentages follow the UI convention of `0..1` fractions. Charts stay empty (no timeseries is +written). Re-applying replaces the static values; `clear` removes the robot and all of them.