mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
descriptionslop
This commit is contained in:
@@ -20,6 +20,29 @@ function createTestDatabase() {
|
||||
return createConfigurationDatabase({ databasePath: path.join(root, 'configuration.sqlite') });
|
||||
}
|
||||
|
||||
function collectUndocumentedSchemaPaths(schema, pathLabel = '$') {
|
||||
/*
|
||||
The admin editor is entirely schema-generated, so missing schema prose is
|
||||
missing operator documentation. Walk objects, arrays, array item schemas,
|
||||
and scalar leaves instead of checking only named service definitions; this
|
||||
makes every visible level of the hierarchy uphold the same contract.
|
||||
*/
|
||||
if (!schema || typeof schema !== 'object') return [];
|
||||
const missing = typeof schema.description === 'string' && schema.description.trim()
|
||||
? []
|
||||
: [pathLabel];
|
||||
|
||||
if (schema.properties) {
|
||||
Object.entries(schema.properties).forEach(([key, childSchema]) => {
|
||||
missing.push(...collectUndocumentedSchemaPaths(childSchema, `${pathLabel}.${key}`));
|
||||
});
|
||||
}
|
||||
if (schema.items) {
|
||||
missing.push(...collectUndocumentedSchemaPaths(schema.items, `${pathLabel}[]`));
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
|
||||
});
|
||||
@@ -46,6 +69,15 @@ test('service definitions determine document order and write-only secret handlin
|
||||
assert.equal(rootSchema.properties.discord.properties.token.writeOnly, true);
|
||||
});
|
||||
|
||||
test('every configuration section, collection, item, and option has an operator description', () => {
|
||||
/*
|
||||
New configuration remains self-documenting by default. Reporting every
|
||||
dotted path in one assertion gives a contributor an exact repair list and
|
||||
avoids recreating a separately maintained documentation registry.
|
||||
*/
|
||||
assert.deepEqual(collectUndocumentedSchemaPaths(rootSchema), []);
|
||||
});
|
||||
|
||||
test('service definitions generate public feature paths without a separate registry', () => {
|
||||
/*
|
||||
This order follows the one configuration document, including nested Neato
|
||||
|
||||
@@ -61,6 +61,7 @@ const properties = Object.fromEntries(
|
||||
);
|
||||
const rootSchema = strictObject(properties, {
|
||||
title: 'Configuration',
|
||||
description: 'Complete server configuration. Changes are validated and saved as one revision, then loaded when the application restarts.',
|
||||
required: definitions.map(({ key }) => key),
|
||||
});
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ module.exports = {
|
||||
externalSpectatorAccess: 'on',
|
||||
},
|
||||
schema: strictObject({
|
||||
multiTabProtection: string({ enum: ['allowed', 'verifiedOnly', 'notAllowed'] }),
|
||||
pauseHiddenRoverVideo: boolean(),
|
||||
multiTabProtection: string({ description: 'Controls multiple active driver tabs: allowed permits everyone, verifiedOnly limits ordinary unverified users, and notAllowed limits all non-admin users.', enum: ['allowed', 'verifiedOnly', 'notAllowed'] }),
|
||||
pauseHiddenRoverVideo: boolean({ description: 'Stops a rover video player while its browser surface is hidden, reducing unnecessary client and server bandwidth.' }),
|
||||
nonTurnVideo: strictObject({
|
||||
mode: string({ enum: ['snapshots', 'live'] }),
|
||||
userThreshold: integer({ minimum: 0, maximum: 100000 }),
|
||||
}, { required: ['mode', 'userThreshold'] }),
|
||||
externalSpectatorVideo: string({ enum: ['snapshots', 'live'] }),
|
||||
externalSpectatorAccess: string({ enum: ['off', 'on', 'verifiedOnly', 'admin'] }),
|
||||
}, { title: 'Bandwidth savings', required: ['multiTabProtection', 'pauseHiddenRoverVideo', 'nonTurnVideo', 'externalSpectatorVideo', 'externalSpectatorAccess'] }),
|
||||
mode: string({ description: 'snapshots replaces non-turn live rover video after the threshold is exceeded; live always permits live video.', enum: ['snapshots', 'live'] }),
|
||||
userThreshold: integer({ description: 'Maximum controllable-user count allowed before snapshot mode activates for non-turn viewers; zero activates it whenever any controllable user exists.', minimum: 0, maximum: 100000 }),
|
||||
}, { description: 'Controls whether users who are not currently driving receive live rover video or periodic snapshots.', required: ['mode', 'userThreshold'] }),
|
||||
externalSpectatorVideo: string({ description: 'Video delivered to non-local spectator pages: snapshots conserves upload bandwidth, while live permits continuous playback.', enum: ['snapshots', 'live'] }),
|
||||
externalSpectatorAccess: string({ description: 'Access for ordinary non-local spectators: off denies them, on permits them, verifiedOnly requires verification, and admin requires the spectator access grant. Local users and administrators remain allowed.', enum: ['off', 'on', 'verifiedOnly', 'admin'] }),
|
||||
}, { title: 'Bandwidth savings', description: 'Defines server-owned policies for duplicate driver tabs and when live video is replaced with snapshots.', required: ['multiTabProtection', 'pauseHiddenRoverVideo', 'nonTurnVideo', 'externalSpectatorVideo', 'externalSpectatorAccess'] }),
|
||||
};
|
||||
|
||||
@@ -7,9 +7,9 @@ module.exports = {
|
||||
key: 'audioForward',
|
||||
defaultValue: { enabled: true, ffmpegBin: 'ffmpeg', streamSuffix: '-fwd', maxUploadBytes: 8388608 },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
ffmpegBin: string({ title: 'ffmpeg executable', minLength: 1, maxLength: 500 }),
|
||||
streamSuffix: string({ minLength: 1, maxLength: 80 }),
|
||||
maxUploadBytes: integer({ minimum: 262144, maximum: 1073741824 }),
|
||||
}, { title: 'Audio forwarding', required: ['enabled', 'ffmpegBin', 'streamSuffix', 'maxUploadBytes'] }),
|
||||
enabled: boolean({ description: 'Enables verified current drivers to publish microphone or uploaded audio to their assigned rover.' }),
|
||||
ffmpegBin: string({ title: 'ffmpeg executable', description: 'Executable name or path used to run the long-lived audio publishing and playback workers.', minLength: 1, maxLength: 500 }),
|
||||
streamSuffix: string({ description: 'Suffix appended to each rover ID to form its internal MediaMTX forwarded-audio stream path.', minLength: 1, maxLength: 80 }),
|
||||
maxUploadBytes: integer({ description: 'Maximum accepted size in bytes for one uploaded audio clip.', minimum: 262144, maximum: 1073741824 }),
|
||||
}, { title: 'Audio forwarding', description: 'Controls browser-to-rover audio publishing, temporary uploaded clips, and the ffmpeg workers that feed MediaMTX.', required: ['enabled', 'ffmpegBin', 'streamSuffix', 'maxUploadBytes'] }),
|
||||
};
|
||||
|
||||
@@ -7,9 +7,9 @@ module.exports = {
|
||||
key: 'audioLevels',
|
||||
defaultValue: { hornGain: 1, ttsGain: 1, forwardGain: 1, maxPersonalAdjustmentPercent: 50 },
|
||||
schema: strictObject({
|
||||
hornGain: number({ minimum: 0, maximum: 4 }),
|
||||
ttsGain: number({ title: 'TTS gain', minimum: 0, maximum: 4 }),
|
||||
forwardGain: number({ minimum: 0, maximum: 4 }),
|
||||
maxPersonalAdjustmentPercent: integer({ minimum: 0, maximum: 100 }),
|
||||
}, { title: 'Audio levels', required: ['hornGain', 'ttsGain', 'forwardGain', 'maxPersonalAdjustmentPercent'] }),
|
||||
hornGain: number({ description: 'Server base multiplier for external horn playback, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
|
||||
ttsGain: number({ title: 'TTS gain', description: 'Server base multiplier for text-to-speech playback, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
|
||||
forwardGain: number({ description: 'Server base multiplier for browser-forwarded and uploaded audio, from silent at 0 through four times gain at 4.', minimum: 0, maximum: 4 }),
|
||||
maxPersonalAdjustmentPercent: integer({ description: 'Largest positive or negative percentage adjustment permitted for users granted personal audio controls; zero disables personal variation.', minimum: 0, maximum: 100 }),
|
||||
}, { title: 'Audio levels', description: 'Sets the initial server-owned playback gains and the allowed range for per-user adjustments.', required: ['hornGain', 'ttsGain', 'forwardGain', 'maxPersonalAdjustmentPercent'] }),
|
||||
};
|
||||
|
||||
@@ -7,5 +7,12 @@ module.exports = {
|
||||
key: 'balanceBoard',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, simulate: false },
|
||||
schema: strictObject({ enabled: boolean(), simulate: boolean() }, { title: 'Balance Board', required: ['enabled', 'simulate'] }),
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Starts the Wii Balance Board service and exposes its readings and controls after restart.' }),
|
||||
simulate: boolean({ description: 'Runs the native worker with generated cyclic sensor data instead of connecting to Bluetooth hardware.' }),
|
||||
}, {
|
||||
title: 'Balance Board',
|
||||
description: 'Optional Wii Balance Board input service with a development simulation mode.',
|
||||
required: ['enabled', 'simulate'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -8,8 +8,8 @@ module.exports = {
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, botName: 'Barcode Games', profileImageUrl: '' },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
botName: string({ minLength: 1, maxLength: 80 }),
|
||||
profileImageUrl: string({ title: 'Profile image URL', maxLength: 2048 }),
|
||||
}, { title: 'Barcode games', required: ['enabled', 'botName', 'profileImageUrl'] }),
|
||||
enabled: boolean({ description: 'Enables shared barcode-game voting, participation, scoring, and game-state publication.' }),
|
||||
botName: string({ description: 'Nickname used for barcode-game lifecycle messages posted into chat.', minLength: 1, maxLength: 80 }),
|
||||
profileImageUrl: string({ title: 'Profile image URL', description: 'Optional image URL displayed beside barcode-game chat messages; leave blank for no custom image.', maxLength: 2048 }),
|
||||
}, { title: 'Barcode games', description: 'Controls the multiplayer games driven by scans received from the barcode scanner service.', required: ['enabled', 'botName', 'profileImageUrl'] }),
|
||||
};
|
||||
|
||||
@@ -7,5 +7,11 @@ module.exports = {
|
||||
key: 'barcodeScanner',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false },
|
||||
schema: strictObject({ enabled: boolean() }, { title: 'Barcode scanner', required: ['enabled'] }),
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Registers barcode scanning, barcode administration, and scan-triggered server behavior after restart.' }),
|
||||
}, {
|
||||
title: 'Barcode scanner',
|
||||
description: 'Optional physical barcode scanning and barcode registry service.',
|
||||
required: ['enabled'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -7,5 +7,11 @@ module.exports = {
|
||||
key: 'buttonBox',
|
||||
feature: true,
|
||||
defaultValue: { enabled: false },
|
||||
schema: strictObject({ enabled: boolean() }, { title: 'Button box', required: ['enabled'] }),
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Registers the physical button-box input route and enables its persistent button rewards and effects after restart.' }),
|
||||
}, {
|
||||
title: 'Button box',
|
||||
description: 'Optional physical button-box input and reward system.',
|
||||
required: ['enabled'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -15,22 +15,34 @@ module.exports = {
|
||||
roles: { stalkerPing: '', announcementPing: '', adminPing: '', humanAlertPing: '' },
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
token: string({ title: 'Bot token', writeOnly: true, maxLength: 10000 }),
|
||||
guildId: string({ title: 'Guild id', maxLength: 100 }),
|
||||
siteUrl: string({ title: 'Public site URL', maxLength: 2048 }),
|
||||
enabled: boolean({ description: 'Logs the Discord bot in and enables commands, chat bridges, replay delivery, and configured announcements after restart.' }),
|
||||
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', writeOnly: true, maxLength: 10000 }),
|
||||
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', maxLength: 100 }),
|
||||
siteUrl: string({ title: 'Public site URL', description: 'Public base URL appended to announcement embeds and server-hosted replay links.', maxLength: 2048 }),
|
||||
channels: strictObject({
|
||||
general: string({ maxLength: 100 }),
|
||||
announcements: string({ maxLength: 100 }),
|
||||
adminAlerts: string({ maxLength: 100 }),
|
||||
replay: string({ maxLength: 100 }),
|
||||
humanAlerts: string({ maxLength: 100 }),
|
||||
}, { required: ['general', 'announcements', 'adminAlerts', 'replay', 'humanAlerts'] }),
|
||||
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', maxLength: 100 }),
|
||||
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', maxLength: 100 }),
|
||||
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', maxLength: 100 }),
|
||||
replay: string({ description: 'Channel ID used to upload generated replay videos when Discord replay delivery is available.', maxLength: 100 }),
|
||||
humanAlerts: string({ description: 'Channel ID used for physical human-alert button notifications and captured images.', maxLength: 100 }),
|
||||
}, {
|
||||
title: 'Channels',
|
||||
description: 'Discord channel IDs that route each category of bot output.',
|
||||
required: ['general', 'announcements', 'adminAlerts', 'replay', 'humanAlerts'],
|
||||
}),
|
||||
roles: strictObject({
|
||||
stalkerPing: string({ maxLength: 100 }),
|
||||
announcementPing: string({ maxLength: 100 }),
|
||||
adminPing: string({ maxLength: 100 }),
|
||||
humanAlertPing: string({ maxLength: 100 }),
|
||||
}, { required: ['stalkerPing', 'announcementPing', 'adminPing', 'humanAlertPing'] }),
|
||||
}, { title: 'Discord', required: ['enabled', 'token', 'guildId', 'siteUrl', 'channels', 'roles'] }),
|
||||
stalkerPing: string({ description: 'Role ID mentioned by the button-box stalker-ping reward in the general channel.', maxLength: 100 }),
|
||||
announcementPing: string({ description: 'Role ID mentioned by configured user announcements.', maxLength: 100 }),
|
||||
adminPing: string({ description: 'Role ID mentioned for important administrative rover, battery, and help alerts.', maxLength: 100 }),
|
||||
humanAlertPing: string({ description: 'Role ID mentioned when the physical human-alert button is pressed.', maxLength: 100 }),
|
||||
}, {
|
||||
title: 'Roles',
|
||||
description: 'Discord role IDs mentioned for specific notification categories.',
|
||||
required: ['stalkerPing', 'announcementPing', 'adminPing', 'humanAlertPing'],
|
||||
}),
|
||||
}, {
|
||||
title: 'Discord',
|
||||
description: 'Optional Discord bot credentials, public URL, and notification routing.',
|
||||
required: ['enabled', 'token', 'guildId', 'siteUrl', 'channels', 'roles'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -14,21 +14,43 @@ module.exports = {
|
||||
privacy: { retainChatBodies: true },
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
enabled: boolean({ description: 'Starts persistent fleet metric collection, reports, retention cleanup, and configured daily delivery after restart.' }),
|
||||
retention: strictObject({
|
||||
detailedDays: integer({ description: 'Zero retains indefinitely.', minimum: 0, maximum: 36500 }),
|
||||
minuteSamplesDays: integer({ description: 'Zero retains indefinitely.', minimum: 0, maximum: 36500 }),
|
||||
}, { required: ['detailedDays', 'minuteSamplesDays'] }),
|
||||
detailedDays: integer({ description: 'Days to retain detailed events, command observations, sessions, and other non-minute fleet records. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
|
||||
minuteSamplesDays: integer({ description: 'Days to retain per-minute rover metric aggregates. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
|
||||
}, {
|
||||
title: 'Retention',
|
||||
description: 'Automatic cleanup windows for the two classes of fleet-report records.',
|
||||
required: ['detailedDays', 'minuteSamplesDays'],
|
||||
}),
|
||||
battery: strictObject({
|
||||
enabled: boolean(),
|
||||
maximumIntegrationGapSeconds: number({ minimum: 0.1, maximum: 3600 }),
|
||||
minimumCapacityTestDepthPercent: number({ minimum: 0, maximum: 100 }),
|
||||
}, { required: ['enabled', 'maximumIntegrationGapSeconds', 'minimumCapacityTestDepthPercent'] }),
|
||||
enabled: boolean({ description: 'Collects high-frequency battery sensor readings and derives charging, discharge, energy, and capacity metrics.' }),
|
||||
maximumIntegrationGapSeconds: number({ description: 'Largest allowed gap in seconds between battery readings before energy integration treats the telemetry as discontinuous.', minimum: 0.1, maximum: 3600 }),
|
||||
minimumCapacityTestDepthPercent: number({ description: 'Minimum observed full-to-low discharge depth required before a continuous session qualifies as a high-confidence capacity test. Runtime enforces at least 10 percent.', minimum: 0, maximum: 100 }),
|
||||
}, {
|
||||
title: 'Battery',
|
||||
description: 'Battery telemetry collection and quality thresholds used by fleet reports.',
|
||||
required: ['enabled', 'maximumIntegrationGapSeconds', 'minimumCapacityTestDepthPercent'],
|
||||
}),
|
||||
discord: strictObject({
|
||||
enabled: boolean(),
|
||||
sendAt: string({ pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }),
|
||||
timezone: string({ minLength: 1, maxLength: 100 }),
|
||||
}, { required: ['enabled', 'sendAt', 'timezone'] }),
|
||||
privacy: strictObject({ retainChatBodies: boolean() }, { required: ['retainChatBodies'] }),
|
||||
}, { title: 'Fleet reports', required: ['enabled', 'retention', 'battery', 'discord', 'privacy'] }),
|
||||
enabled: boolean({ description: 'Sends one completed daily fleet report to the configured Discord administrative-alert channel.' }),
|
||||
sendAt: string({ description: 'Local time of day to send the daily report, written as 24-hour HH:mm.', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' }),
|
||||
timezone: string({ description: 'IANA timezone used to interpret the delivery time and determine each completed report day.', minLength: 1, maxLength: 100 }),
|
||||
}, {
|
||||
title: 'Discord delivery',
|
||||
description: 'Schedule for sending completed daily fleet summaries through the Discord bot.',
|
||||
required: ['enabled', 'sendAt', 'timezone'],
|
||||
}),
|
||||
privacy: strictObject({
|
||||
retainChatBodies: boolean({ description: 'Reserved privacy preference. The current collector does not read this setting and preserves complete event payloads, including chat content, regardless of its value.' }),
|
||||
}, {
|
||||
title: 'Privacy',
|
||||
description: 'Privacy controls reserved for any future fleet-report collection of message content.',
|
||||
required: ['retainChatBodies'],
|
||||
}),
|
||||
}, {
|
||||
title: 'Fleet reports',
|
||||
description: 'Persistent fleet operations reporting, retention, battery analysis, delivery, and privacy preferences.',
|
||||
required: ['enabled', 'retention', 'battery', 'discord', 'privacy'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -21,29 +21,41 @@ module.exports = {
|
||||
buttons: [],
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
url: string({ title: 'Server URL', format: 'uri', maxLength: 2048 }),
|
||||
token: string({ title: 'Long-lived access token', writeOnly: true, maxLength: 20000 }),
|
||||
enabled: boolean({ description: 'Connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls after restart.' }),
|
||||
url: string({ title: 'Server URL', description: 'Base URL of the Home Assistant server used for its REST and WebSocket APIs.', format: 'uri', maxLength: 2048 }),
|
||||
token: string({ title: 'Long-lived access token', description: 'Home Assistant long-lived access token used to authenticate every API request. The saved value is never returned to the browser.', writeOnly: true, maxLength: 20000 }),
|
||||
[neato.key]: neato.schema,
|
||||
[lift.key]: lift.schema,
|
||||
entities: {
|
||||
type: 'array',
|
||||
title: 'Room entities',
|
||||
description: 'Home Assistant lights and switches exposed to the room-light controls and button-box actions.',
|
||||
items: strictObject({
|
||||
id: string({ title: 'Entity id', minLength: 1, maxLength: 255 }),
|
||||
name: string({ minLength: 1, maxLength: 120 }),
|
||||
type: string({ enum: ['light', 'switch'] }),
|
||||
}, { required: ['id', 'name'] }),
|
||||
id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID, such as light.rover_room or switch.floor_lamp.', minLength: 1, maxLength: 255 }),
|
||||
name: string({ description: 'Human-readable name shown for this entity in the rover UI.', minLength: 1, maxLength: 120 }),
|
||||
type: string({ description: 'Control behavior to expose: lights receive brightness-aware commands, while switches receive simple on and off commands.', enum: ['light', 'switch'] }),
|
||||
}, {
|
||||
description: 'One Home Assistant entity that the rover server can display and control.',
|
||||
required: ['id', 'name'],
|
||||
}),
|
||||
},
|
||||
buttons: {
|
||||
type: 'array',
|
||||
title: 'Physical button mappings',
|
||||
description: 'Maps Home Assistant entity state changes to built-in rover-server actions.',
|
||||
items: strictObject({
|
||||
entityId: string({ title: 'Entity id', minLength: 1, maxLength: 255 }),
|
||||
stateEquals: string({ minLength: 1, maxLength: 255 }),
|
||||
cooldownMs: integer({ minimum: 0, maximum: 86400000 }),
|
||||
action: string({ enum: ['humanAlert', 'modeTurns', 'modeAdmin', 'lightsLockToggle'] }),
|
||||
}, { required: ['entityId', 'stateEquals', 'cooldownMs', 'action'] }),
|
||||
entityId: string({ title: 'Entity id', description: 'Home Assistant entity whose state changes are watched as button presses.', minLength: 1, maxLength: 255 }),
|
||||
stateEquals: string({ description: 'Exact Home Assistant state that must be reached before the action fires.', minLength: 1, maxLength: 255 }),
|
||||
cooldownMs: integer({ description: 'Minimum milliseconds between accepted activations of this mapping.', minimum: 0, maximum: 86400000 }),
|
||||
action: string({ description: 'Built-in action to run: raise a human alert, switch to turns mode, switch to admin mode, or toggle the room-light lock.', enum: ['humanAlert', 'modeTurns', 'modeAdmin', 'lightsLockToggle'] }),
|
||||
}, {
|
||||
description: 'One watched Home Assistant state transition and the server action it triggers.',
|
||||
required: ['entityId', 'stateEquals', 'cooldownMs', 'action'],
|
||||
}),
|
||||
},
|
||||
}, { title: 'Home Assistant', required: ['enabled', 'url', 'token', 'neato', 'lift', 'entities', 'buttons'] }),
|
||||
}, {
|
||||
title: 'Home Assistant',
|
||||
description: 'Connection, controllable entity catalog, and hardware-trigger mappings for the shared Home Assistant integration.',
|
||||
required: ['enabled', 'url', 'token', 'neato', 'lift', 'entities', 'buttons'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -14,15 +14,18 @@ module.exports = {
|
||||
profile: { publicUrl: '', name: 'MultiRover', description: '', color: '#38bdf8' },
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
directoryUrls: stringArray({ item: { format: 'uri' } }),
|
||||
pollIntervalMs: integer({ minimum: 1000, maximum: 86400000 }),
|
||||
requestTimeoutMs: integer({ minimum: 250, maximum: 120000 }),
|
||||
enabled: boolean({ description: 'Publishes this server\'s public instance information and polls the configured directories for peer servers.' }),
|
||||
directoryUrls: stringArray({
|
||||
item: { description: 'Absolute URL returning an array of peer MultiRover instance entries.', format: 'uri' },
|
||||
array: { description: 'Directory endpoints polled to discover other public MultiRover servers.' },
|
||||
}),
|
||||
pollIntervalMs: integer({ description: 'Milliseconds between peer-directory refreshes.', minimum: 1000, maximum: 86400000 }),
|
||||
requestTimeoutMs: integer({ description: 'Maximum milliseconds allowed for each directory or peer information request before it is aborted.', minimum: 250, maximum: 120000 }),
|
||||
profile: strictObject({
|
||||
publicUrl: string({ maxLength: 2048 }),
|
||||
name: string({ minLength: 1, maxLength: 120 }),
|
||||
description: string({ maxLength: 500 }),
|
||||
color: string({ pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
}, { required: ['publicUrl', 'name', 'description', 'color'] }),
|
||||
}, { title: 'Inter-instance directory', required: ['enabled', 'directoryUrls', 'pollIntervalMs', 'requestTimeoutMs', 'profile'] }),
|
||||
publicUrl: string({ description: 'Public base URL peers and users use to reach this server; it also identifies and filters this instance from directory results.', maxLength: 2048 }),
|
||||
name: string({ description: 'Public instance name advertised to peer servers.', minLength: 1, maxLength: 120 }),
|
||||
description: string({ description: 'Short public summary advertised with this instance.', maxLength: 500 }),
|
||||
color: string({ description: 'Six-digit hexadecimal accent color advertised for this instance.', pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
}, { description: 'Public identity this server publishes through the inter-instance information endpoint.', required: ['publicUrl', 'name', 'description', 'color'] }),
|
||||
}, { title: 'Inter-instance directory', description: 'Controls discovery and public information exchange between independent MultiRover servers.', required: ['enabled', 'directoryUrls', 'pollIntervalMs', 'requestTimeoutMs', 'profile'] }),
|
||||
};
|
||||
|
||||
@@ -8,7 +8,11 @@ module.exports = {
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, captureCooldownMs: 10000 },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
captureCooldownMs: integer({ minimum: 0, maximum: 3600000 }),
|
||||
}, { title: 'Kinect', required: ['enabled', 'captureCooldownMs'] }),
|
||||
enabled: boolean({ description: 'Starts the Kinect worker and exposes authorized frame capture after restart.' }),
|
||||
captureCooldownMs: integer({ description: 'Minimum milliseconds between accepted Kinect frame-capture requests across all clients.', minimum: 0, maximum: 3600000 }),
|
||||
}, {
|
||||
title: 'Kinect',
|
||||
description: 'Optional Kinect frame capture and its server-wide request cooldown.',
|
||||
required: ['enabled', 'captureCooldownMs'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -8,10 +8,14 @@ module.exports = {
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, upSwitch: '', downSwitch: '', interlockMs: 2000, commandCooldownMs: 3000 },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
upSwitch: string({ maxLength: 255 }),
|
||||
downSwitch: string({ maxLength: 255 }),
|
||||
interlockMs: integer({ minimum: 0, maximum: 600000 }),
|
||||
commandCooldownMs: integer({ minimum: 0, maximum: 600000 }),
|
||||
}, { title: 'Lift', required: ['enabled', 'upSwitch', 'downSwitch', 'interlockMs', 'commandCooldownMs'] }),
|
||||
enabled: boolean({ description: 'Enables lift status and commands through the two configured Home Assistant switches after restart.' }),
|
||||
upSwitch: string({ description: 'Home Assistant switch entity that powers upward lift movement.', maxLength: 255 }),
|
||||
downSwitch: string({ description: 'Home Assistant switch entity that powers downward lift movement.', maxLength: 255 }),
|
||||
interlockMs: integer({ description: 'Milliseconds to wait after turning off the opposing direction before energizing the requested direction. Runtime always enforces at least 250 ms.', minimum: 0, maximum: 600000 }),
|
||||
commandCooldownMs: integer({ description: 'Minimum milliseconds between lift commands. Runtime never allows this to be shorter than the interlock delay.', minimum: 0, maximum: 600000 }),
|
||||
}, {
|
||||
title: 'Lift',
|
||||
description: 'Bidirectional lift control using interlocked Home Assistant switch entities.',
|
||||
required: ['enabled', 'upSwitch', 'downSwitch', 'interlockMs', 'commandCooldownMs'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -7,9 +7,9 @@ module.exports = {
|
||||
key: 'llmCommentary',
|
||||
defaultValue: { enabled: false, model: 'qwen2.5:7b-instruct', ollamaServer: 'http://127.0.0.1:11434', frequency: 120000 },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
model: string({ minLength: 1, maxLength: 200 }),
|
||||
ollamaServer: string({ title: 'Ollama server', format: 'uri', maxLength: 2048 }),
|
||||
enabled: boolean({ description: 'Starts periodic AI commentary generation from current rover, user, and chat activity.' }),
|
||||
model: string({ description: 'Ollama model name used to generate commentary.', minLength: 1, maxLength: 200 }),
|
||||
ollamaServer: string({ title: 'Ollama server', description: 'Base URL of the Ollama API used for commentary generation.', format: 'uri', maxLength: 2048 }),
|
||||
frequency: integer({ description: 'Commentary interval in milliseconds.', minimum: 1000, maximum: 86400000 }),
|
||||
}, { title: 'LLM commentary', required: ['enabled', 'model', 'ollamaServer', 'frequency'] }),
|
||||
}, { title: 'LLM commentary', description: 'Generates periodic AI-authored chat commentary from recent server activity through Ollama.', required: ['enabled', 'model', 'ollamaServer', 'frequency'] }),
|
||||
};
|
||||
|
||||
@@ -7,7 +7,11 @@ module.exports = {
|
||||
key: 'media',
|
||||
defaultValue: { whepBaseUrl: 'http://127.0.0.1:8889/video', additionalHosts: [] },
|
||||
schema: strictObject({
|
||||
whepBaseUrl: string({ title: 'WHEP base URL', format: 'uri', maxLength: 2048 }),
|
||||
additionalHosts: stringArray({ title: 'Additional ICE hosts', item: { minLength: 1, maxLength: 255 }, array: { uniqueItems: true } }),
|
||||
}, { title: 'Media', required: ['whepBaseUrl', 'additionalHosts'] }),
|
||||
whepBaseUrl: string({ title: 'WHEP base URL', description: 'Base HTTP URL used to build browser WHEP playback and WHIP audio-publishing endpoints.', format: 'uri', maxLength: 2048 }),
|
||||
additionalHosts: stringArray({
|
||||
title: 'Additional ICE hosts',
|
||||
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', minLength: 1, maxLength: 255 },
|
||||
array: { description: 'Additional public or LAN hostnames and addresses browsers may use to reach MediaMTX WebRTC transport.', uniqueItems: true },
|
||||
}),
|
||||
}, { title: 'Media', description: 'Controls browser signaling addresses and WebRTC network candidates generated for the managed MediaMTX process.', required: ['whepBaseUrl', 'additionalHosts'] }),
|
||||
};
|
||||
|
||||
@@ -8,7 +8,11 @@ module.exports = {
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, device: '' },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
device: string({ description: 'ESPHome device name.', maxLength: 255 }),
|
||||
}, { title: 'Neato', required: ['enabled', 'device'] }),
|
||||
enabled: boolean({ description: 'Exposes Neato status and commands through the configured Home Assistant ESPHome device after restart.' }),
|
||||
device: string({ description: 'ESPHome device name used to derive the Neato entity IDs in Home Assistant; punctuation is normalized to underscores.', maxLength: 255 }),
|
||||
}, {
|
||||
title: 'Neato',
|
||||
description: 'Optional Neato robot controls backed by entities published from one ESPHome device through Home Assistant.',
|
||||
required: ['enabled', 'device'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -7,7 +7,11 @@ module.exports = {
|
||||
key: 'commands',
|
||||
defaultValue: { prefix: 'rs', timeStatusCommand: 'ts' },
|
||||
schema: strictObject({
|
||||
prefix: string({ minLength: 1, maxLength: 20 }),
|
||||
timeStatusCommand: nullableString({ description: 'Leave empty to disable the bare shortcut.', maxLength: 20 }),
|
||||
}, { title: 'Commands', required: ['prefix', 'timeStatusCommand'] }),
|
||||
prefix: string({ description: 'Text placed before operator commands in web chat and Discord, such as rs help.', minLength: 1, maxLength: 20 }),
|
||||
timeStatusCommand: nullableString({ description: 'Optional command accepted without the normal prefix for the current time and rover status. Leave empty to disable the shortcut.', maxLength: 20 }),
|
||||
}, {
|
||||
title: 'Commands',
|
||||
description: 'Shared text syntax used by operator commands across web chat and Discord.',
|
||||
required: ['prefix', 'timeStatusCommand'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -19,16 +19,16 @@ module.exports = {
|
||||
gateIntervalMs: 2000,
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
mode: string({ enum: ['autonomous', 'directAddress'] }),
|
||||
observeOnly: boolean(),
|
||||
postToolsOnlyMessages: boolean(),
|
||||
tiebreakerEnable: boolean(),
|
||||
runWhileNoPeopleOnline: boolean(),
|
||||
name: string({ minLength: 1, maxLength: 80 }),
|
||||
model: string({ minLength: 1, maxLength: 200 }),
|
||||
ollamaServer: string({ title: 'Ollama server', format: 'uri', maxLength: 2048 }),
|
||||
profileImageUrl: string({ title: 'Profile image URL', maxLength: 2048 }),
|
||||
gateIntervalMs: integer({ minimum: 250, maximum: 3600000 }),
|
||||
}, { title: 'Overseer Control', required: ['enabled', 'mode', 'observeOnly', 'postToolsOnlyMessages', 'tiebreakerEnable', 'runWhileNoPeopleOnline', 'name', 'model', 'ollamaServer', 'profileImageUrl', 'gateIntervalMs'] }),
|
||||
enabled: boolean({ description: 'Enables the AI Overseer and its configured autonomous or direct-address execution path.' }),
|
||||
mode: string({ description: 'autonomous runs repeatedly when the user vote permits it; directAddress runs only when chat begins with the configured Overseer name.', enum: ['autonomous', 'directAddress'] }),
|
||||
observeOnly: boolean({ description: 'Lets the model evaluate state without executing tools or posting its generated chat response.' }),
|
||||
postToolsOnlyMessages: boolean({ description: 'Posts a chat feed entry for executed tool calls even when the model did not also produce chat text.' }),
|
||||
tiebreakerEnable: boolean({ description: 'Allows autonomous execution when eligible users are evenly split between enabling and disabling the Overseer.' }),
|
||||
runWhileNoPeopleOnline: boolean({ description: 'Allows autonomous execution when no eligible users are online to vote.' }),
|
||||
name: string({ description: 'Chat identity for Overseer messages and the phrase that triggers direct-address mode.', minLength: 1, maxLength: 80 }),
|
||||
model: string({ description: 'Ollama model name used for Overseer decisions.', minLength: 1, maxLength: 200 }),
|
||||
ollamaServer: string({ title: 'Ollama server', description: 'Base URL of the Ollama API used for Overseer decisions.', format: 'uri', maxLength: 2048 }),
|
||||
profileImageUrl: string({ title: 'Profile image URL', description: 'Optional image URL displayed beside Overseer chat messages; leave blank for no custom image.', maxLength: 2048 }),
|
||||
gateIntervalMs: integer({ description: 'Milliseconds waited after a completed autonomous decision before evaluating the next one.', minimum: 250, maximum: 3600000 }),
|
||||
}, { title: 'Overseer Control', description: 'Controls the AI agent that observes server state, optionally executes approved tools, and can speak in chat.', required: ['enabled', 'mode', 'observeOnly', 'postToolsOnlyMessages', 'tiebreakerEnable', 'runWhileNoPeopleOnline', 'name', 'model', 'ollamaServer', 'profileImageUrl', 'gateIntervalMs'] }),
|
||||
};
|
||||
|
||||
@@ -19,15 +19,19 @@ module.exports = {
|
||||
replayEnabled: false,
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
name: string({ minLength: 1, maxLength: 120 }),
|
||||
color: string({ pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
host: string({ maxLength: 255 }),
|
||||
onvifPort: integer({ title: 'ONVIF port', minimum: 1, maximum: 65535 }),
|
||||
username: string({ maxLength: 255 }),
|
||||
password: string({ writeOnly: true, maxLength: 10000 }),
|
||||
profileToken: string({ maxLength: 255 }),
|
||||
turnDurationMs: integer({ minimum: 1000, maximum: 86400000 }),
|
||||
replayEnabled: boolean(),
|
||||
}, { title: 'PTZ camera', required: ['enabled', 'name', 'color', 'host', 'onvifPort', 'username', 'password', 'profileToken', 'turnDurationMs', 'replayEnabled'] }),
|
||||
enabled: boolean({ description: 'Connects to the configured ONVIF camera and exposes its controls after restart.' }),
|
||||
name: string({ description: 'Human-readable camera name shown in the control interface.', minLength: 1, maxLength: 120 }),
|
||||
color: string({ description: 'Six-digit hexadecimal accent color used to identify this camera in the UI.', pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
host: string({ description: 'Hostname or IP address of the ONVIF camera.', maxLength: 255 }),
|
||||
onvifPort: integer({ title: 'ONVIF port', description: 'TCP port used for ONVIF control requests.', minimum: 1, maximum: 65535 }),
|
||||
username: string({ description: 'Camera account username used for ONVIF authentication.', maxLength: 255 }),
|
||||
password: string({ description: 'Camera account password used for ONVIF authentication. The saved value is never returned to the browser.', writeOnly: true, maxLength: 10000 }),
|
||||
profileToken: string({ description: 'ONVIF media profile token used for stream discovery, presets, status, and movement commands.', maxLength: 255 }),
|
||||
turnDurationMs: integer({ description: 'Milliseconds assigned to each queued user turn controlling the PTZ camera.', minimum: 1000, maximum: 86400000 }),
|
||||
replayEnabled: boolean({ description: 'Allows this camera to appear as an available replay source.' }),
|
||||
}, {
|
||||
title: 'PTZ camera',
|
||||
description: 'Optional ONVIF pan-tilt-zoom camera connection, presentation, turn timing, and replay availability.',
|
||||
required: ['enabled', 'name', 'color', 'host', 'onvifPort', 'username', 'password', 'profileToken', 'turnDurationMs', 'replayEnabled'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -8,16 +8,24 @@ module.exports = {
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, cameras: [] },
|
||||
schema: strictObject({
|
||||
enabled: boolean(),
|
||||
enabled: boolean({ description: 'Publishes the configured room-camera catalog and enables camera snapshots and streams after restart.' }),
|
||||
cameras: {
|
||||
type: 'array',
|
||||
description: 'Room cameras available to the web UI and replay system.',
|
||||
items: strictObject({
|
||||
id: string({ minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||
name: string({ minLength: 1, maxLength: 120 }),
|
||||
description: string({ maxLength: 500 }),
|
||||
url: string({ title: 'Snapshot URL', format: 'uri', maxLength: 2048 }),
|
||||
streamUrl: string({ title: 'Stream URL', maxLength: 2048 }),
|
||||
}, { required: ['id', 'name', 'description', 'url', 'streamUrl'] }),
|
||||
id: string({ description: 'Stable camera identifier used in socket requests, selections, and replay source names.', minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||
name: string({ description: 'Human-readable camera name shown in the UI.', minLength: 1, maxLength: 120 }),
|
||||
description: string({ description: 'Short explanation of the camera location or view shown in the UI.', maxLength: 500 }),
|
||||
url: string({ title: 'Snapshot URL', description: 'HTTP URL fetched when the server needs a still image from this camera.', format: 'uri', maxLength: 2048 }),
|
||||
streamUrl: string({ title: 'Stream URL', description: 'Live stream URL consumed by the server snapshot engine and replay capture path.', maxLength: 2048 }),
|
||||
}, {
|
||||
description: 'One named room camera with its still-image and live-stream sources.',
|
||||
required: ['id', 'name', 'description', 'url', 'streamUrl'],
|
||||
}),
|
||||
},
|
||||
}, { title: 'Room cameras', required: ['enabled', 'cameras'] }),
|
||||
}, {
|
||||
title: 'Room cameras',
|
||||
description: 'Optional catalog of fixed cameras used for room views, server-produced snapshots, and replay sources.',
|
||||
required: ['enabled', 'cameras'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -14,28 +14,29 @@ const socials = {
|
||||
feature: true,
|
||||
defaultValue: { enabled: false, links: [] },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ title: 'Enabled' }),
|
||||
enabled: boolean({ title: 'Enabled', description: 'Shows the configured social-link buttons in driver and inter-instance views.' }),
|
||||
links: {
|
||||
type: 'array',
|
||||
title: 'Links',
|
||||
description: 'Ordered social or community links presented to users when this feature is enabled.',
|
||||
items: strictObject({
|
||||
id: string({ minLength: 1, maxLength: 60, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||
label: string({ minLength: 1, maxLength: 80 }),
|
||||
url: string({ format: 'uri', maxLength: 2048 }),
|
||||
icon: string({ maxLength: 80 }),
|
||||
color: string({ pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
}, { required: ['id', 'label', 'url', 'icon', 'color'] }),
|
||||
id: string({ description: 'Stable identifier used by the UI to distinguish this link from the others.', minLength: 1, maxLength: 60, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||
label: string({ description: 'User-facing text displayed on the link button.', minLength: 1, maxLength: 80 }),
|
||||
url: string({ description: 'Absolute destination opened when a user selects this link.', format: 'uri', maxLength: 2048 }),
|
||||
icon: string({ description: 'Icon name interpreted by the social-button UI; leave blank to use its fallback presentation.', maxLength: 80 }),
|
||||
color: string({ description: 'Six-digit hexadecimal accent color used for this link button.', pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
}, { description: 'One social-link button shown to users.', required: ['id', 'label', 'url', 'icon', 'color'] }),
|
||||
},
|
||||
}, { title: 'Social links', required: ['enabled', 'links'] }),
|
||||
}, { title: 'Social links', description: 'Controls the optional social and community buttons published to local users and peer instances.', required: ['enabled', 'links'] }),
|
||||
};
|
||||
|
||||
const driverAd = {
|
||||
key: 'driverAd',
|
||||
defaultValue: { title: '', html: '' },
|
||||
schema: strictObject({
|
||||
title: string({ maxLength: 120 }),
|
||||
title: string({ description: 'Heading displayed above the operator-provided content on the driver page; leave blank to use the card fallback.', maxLength: 120 }),
|
||||
html: string({ title: 'HTML', description: 'Trusted operator HTML shown to drivers.', maxLength: 100000 }),
|
||||
}, { title: 'Driver content', required: ['title', 'html'] }),
|
||||
}, { title: 'Driver content', description: 'Operator-managed informational or promotional content displayed in the driver application.', required: ['title', 'html'] }),
|
||||
};
|
||||
|
||||
function getConfiguredSocials(config) {
|
||||
|
||||
Reference in New Issue
Block a user