mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
descriptionslop
This commit is contained in:
@@ -443,6 +443,8 @@ Implemented on 2026-09-14:
|
||||
- Added the centralized `/admin` route with Overview, Fleet operations, Users and administrators, and one schema-generated hierarchical Configuration page in legacy YAML order.
|
||||
- Replaced every feature-specific configuration form with `@rjsf/core`; the protected admin snapshot supplies the server's assembled schema, and one generic widget handles all schema-declared secrets.
|
||||
- Replaced RJSF's unthemed Bootstrap markup with a generic MultiRover tree renderer. The complete document now follows schema order as indented object, array, item, and key/value rows; array controls remain readable text beside each item, and the route-specific styling lives outside the global stylesheet.
|
||||
- Increased only the top-level section spacing while keeping nested configuration lines compact, moved option descriptions into the wider value column, and made root and array-item descriptions visible instead of discarding them.
|
||||
- Traced all 156 schema nodes to their runtime consumers and added operator-facing descriptions for every root, section, collection, array item, and scalar option. A recursive configuration test now rejects any future schema node without a description; currently reserved settings explicitly state that they have no runtime effect.
|
||||
- Converged feature control into service-owned configuration: each public feature opts in beside its own schema, and the configuration system derives those exact `enabled` switches for sessions and command discovery. The former server feature registry was removed; configuration completeness and hardware availability remain visible as runtime status instead of becoming hidden enablement rules.
|
||||
- Lazy-loaded setup and administration so the schema-form dependency is not included in ordinary driver-page downloads.
|
||||
- Reused the existing fleet and identity administration surfaces, added password reconfirmation for sensitive operations, and prevented removal or demotion of the final lockdown administrator.
|
||||
@@ -450,7 +452,7 @@ Implemented on 2026-09-14:
|
||||
|
||||
Local verification completed:
|
||||
|
||||
- All 103 server tests passed, including file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, explicit setup-file import, and the earlier filesystem coverage.
|
||||
- All 104 server tests passed, including complete schema-description coverage, file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, explicit setup-file import, and the earlier filesystem coverage.
|
||||
- Focused admin, route, and identity UI lint passed.
|
||||
- All 20 existing focused web UI tests passed.
|
||||
- The production web UI build completed successfully and regenerated the checked-in server assets.
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.configuration-branch{min-width:0px}.configuration-branch-heading,.configuration-item-heading{display:flex;flex-wrap:wrap;align-items:center;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity));padding:.125rem}.configuration-branch-heading h3,.configuration-item-title{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-children{margin-left:.25rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-children{border-left-width:1px;border-color:#0369a1b3;padding-top:.125rem;padding-bottom:.125rem;padding-left:.25rem}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.7rem;line-height:1.25;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{margin-left:.125rem;max-width:56rem}.configuration-value-description{margin-bottom:.125rem;max-width:40rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-array-item+.configuration-array-item{margin-top:.125rem}
|
||||
@@ -1 +0,0 @@
|
||||
.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-branch{min-width:0px}.configuration-branch-heading,.configuration-item-heading{display:flex;flex-wrap:wrap;align-items:center;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity));padding:.125rem}.configuration-branch-heading h3,.configuration-item-title{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-branch-heading>div,.configuration-branch-description{font-size:.7rem;--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-children{margin-left:.25rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-children{border-left-width:1px;border-color:#0369a1b3;padding-top:.125rem;padding-bottom:.125rem;padding-left:.25rem}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-key .field-description,.configuration-key .help-block,.configuration-branch-description{display:block;font-size:.7rem;line-height:1.25;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity));margin-top:.125rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-array-item+.configuration-array-item{margin-top:.125rem}
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DNzOwgk-.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-CgxV6A-m.js.map
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-_sqiQMZb.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-CkP7exMm.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-DNzOwgk-.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-_sqiQMZb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-1O6avznD.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -103,9 +103,12 @@ function ConfigurationFieldTemplate({
|
||||
<label htmlFor={id} className="text-xs font-semibold text-slate-100">
|
||||
{label}{required ? <span className="ml-0.25 text-sky-300">*</span> : null}
|
||||
</label>
|
||||
{description ? <div>{description}</div> : null}
|
||||
</div>
|
||||
<div className="configuration-value">
|
||||
{/* Descriptions belong with the editable value rather than inside the
|
||||
narrow key column. This keeps long operational guidance readable
|
||||
without weakening the YAML-like key/value alignment. */}
|
||||
{description ? <div className="configuration-value-description">{description}</div> : null}
|
||||
{children}
|
||||
{errors}
|
||||
{help}
|
||||
@@ -121,22 +124,34 @@ function ConfigurationObjectTemplate({ description, fieldPathId, properties, tit
|
||||
if (fieldPathId.path.length === 0) {
|
||||
// The outer configuration card already names the root document. Rendering
|
||||
// its properties directly makes their schema order read like YAML lines.
|
||||
return <div className="configuration-tree">{propertyLines}</div>;
|
||||
// The root description remains outside that stack so its introductory
|
||||
// text does not accidentally become another configuration section.
|
||||
return (
|
||||
<div>
|
||||
{description ? <div className="configuration-root-description">{description}</div> : null}
|
||||
<div className="configuration-tree">{propertyLines}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof fieldPathId.path.at(-1) === 'number') {
|
||||
// Array items receive their numbered heading and action row from the array
|
||||
// item template. Rendering only their ordered property lines prevents redundant
|
||||
// nested boxes such as "Item 1" followed by another anonymous object box.
|
||||
return <div>{propertyLines}</div>;
|
||||
// item template. Rendering only their description and ordered property
|
||||
// lines prevents redundant boxes while preserving the item's own guidance.
|
||||
return (
|
||||
<div>
|
||||
{description ? <div className="configuration-item-description">{description}</div> : null}
|
||||
{propertyLines}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="configuration-branch">
|
||||
<header className="configuration-branch-heading">
|
||||
<h3>{title}</h3>
|
||||
{description ? <div>{description}</div> : null}
|
||||
</header>
|
||||
{description ? <div className="configuration-branch-description">{description}</div> : null}
|
||||
<div className="configuration-children">{propertyLines}</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
+24
-10
@@ -5,8 +5,9 @@
|
||||
|
||||
.configuration-tree {
|
||||
/* A single ordered stack is the visual equivalent of YAML's top-to-bottom
|
||||
document order. Separation comes from hierarchy, not unrelated tiles. */
|
||||
@apply space-y-0.5;
|
||||
document order. A deliberately large root gap makes the beginning and end
|
||||
of every top-level section unmistakable, while nested lines stay compact. */
|
||||
@apply mt-1 space-y-4;
|
||||
}
|
||||
|
||||
.configuration-branch {
|
||||
@@ -25,11 +26,6 @@
|
||||
@apply text-sm font-semibold text-slate-100;
|
||||
}
|
||||
|
||||
.configuration-branch-heading > div,
|
||||
.configuration-branch-description {
|
||||
@apply text-[0.7rem] text-slate-400;
|
||||
}
|
||||
|
||||
.configuration-children {
|
||||
/* Every container contributes exactly one indentation and one guide. Deep
|
||||
descendants therefore explain their ancestry without calculating depth or
|
||||
@@ -49,13 +45,31 @@
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.configuration-key .field-description,
|
||||
.configuration-key .help-block,
|
||||
.configuration-branch-description {
|
||||
.configuration-root-description,
|
||||
.configuration-branch-description,
|
||||
.configuration-item-description,
|
||||
.configuration-value-description {
|
||||
@apply block text-[0.7rem] leading-tight text-slate-500;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.configuration-root-description {
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
.configuration-branch-description,
|
||||
.configuration-item-description {
|
||||
/* Container help follows its heading at the same indentation; child keys
|
||||
begin below it, preserving the same parent/child rhythm as YAML comments. */
|
||||
@apply ml-0.5 max-w-[56rem];
|
||||
}
|
||||
|
||||
.configuration-value-description {
|
||||
/* A little separation from the widget keeps the help readable without
|
||||
turning each setting back into a large card. */
|
||||
@apply mb-0.5 max-w-[40rem];
|
||||
}
|
||||
|
||||
.configuration-value input:not([type='checkbox']),
|
||||
.configuration-value select,
|
||||
.configuration-value textarea {
|
||||
|
||||
Reference in New Issue
Block a user