Compare commits

...
Author SHA1 Message Date
legop3 7231809140 gonna break stuff now
Container image / image (push) Failing after 51s
2026-09-24 18:36:24 -04:00
legop3 8ca714cf1a pause ptz turns when no ones on 2026-09-24 18:06:24 -04:00
legop3 ffd34de56c improving the UI side of ptz... and improving the way turns works with UI.... 2026-09-24 17:54:46 -04:00
legop3 846c9dae55 new internal concept: operating mode. will allow for more UI and separate pages to be made to operate things other than rovers. Converted PTZ to new system. 2026-09-22 14:42:21 -04:00
legop3 e750712a39 alert feed betterification
Container image / image (push) Failing after 1m0s
2026-09-20 16:22:04 -04:00
legop3 ff8b0c530e add link embedding in chat, update tailwind, update icons, tweak column layout. (8000 files changed because tailwind changed stuff in the update) 2026-09-20 15:41:45 -04:00
legop3 69c5bfe544 Merge pull request #28 from legop3/FEAT-live-discord-cnannel
Feat live discord cnannel
2026-09-20 15:05:57 -04:00
legop3 3ad95b376a add alertfeed to display, adjust alert feed opacities 2026-09-19 22:42:45 -04:00
legop3 bda740b08d add current thing too! and better visuals
Container image / image (push) Failing after 1m0s
2026-09-19 18:43:03 -04:00
legop3 3c8c5f5883 glorm 2026-09-19 17:03:59 -04:00
legop3 c35544e515 add battery voltage to /display so you can see voltages of batteries on the /display page by looking at /display to see the voltages of the batteries on the roombas in case you want to see the battery voltages!! yay!!!! 2026-09-19 17:00:23 -04:00
legop3 0df4a5ec89 smaller cooldown 2026-09-18 16:48:24 -04:00
legop3 8e301e7db7 remove embed stuff 2026-09-18 16:36:19 -04:00
legop3 b712fb89c6 omarchy 2026-09-18 16:05:10 -04:00
legop3 fe2c29371f fix discord bot config restart and startup connection retry
Container image / image (push) Failing after 1m1s
2026-09-18 14:41:11 -04:00
legop3 61ee692815 Merge pull request #27 from legop3/homeassistant-accessories
Homeassistant accessories
2026-09-18 14:27:27 -04:00
legop3 487666eed8 rename old room controls to lights and rename new thing to activity controls
Container image / image (push) Failing after 1m2s
2026-09-16 14:45:09 -04:00
legop3 51b42ea778 undo undo 2026-09-16 13:59:13 -04:00
legop3 318cad1846 slorp 2026-09-16 13:11:16 -04:00
legop3 f3e3b6a803 home assistant entities yay yay 2026-09-16 12:22:41 -04:00
legop3 b48348d89e Merge pull request #26 from legop3/containerizemodernize
Container image / image (push) Failing after 51s
Containerizemodernize
2026-09-16 00:11:07 -04:00
232 changed files with 9897 additions and 6083 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.configuration-card{min-width:0px}.configuration-card-header{top:var(--configuration-sticky-top, 0px)}.configuration-card .configuration-card{margin-left:1rem;width:calc(100% - 1rem)}.configuration-card-body>: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-card-body{padding:.125rem}.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-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-label{font-size:1rem;line-height:1.5rem;font-weight:600;line-height:1.375;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.875rem;line-height:1.25rem;line-height:1.375;--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{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-item-actions,.configuration-array-actions{display:flex;flex-wrap:wrap;gap:.125rem}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
import{r as g,R as a}from"./index-5EWtcenj.js";const d="64px",u={},k=({src:s,light:t,oEmbedUrl:o,onClickPreview:n,playIcon:b,previewTabIndex:_,previewAriaLabel:p})=>{const[f,r]=g.useState(null);g.useEffect(()=>{!s||!t||!o||x({src:s,light:t,oEmbedUrl:o})},[s,t,o]);const x=async({src:e,light:i,oEmbedUrl:E})=>{if(a.isValidElement(i))return;if(typeof i=="string"){r(i);return}if(u[e]){r(u[e]);return}r(null);const m=await(await fetch(E.replace("{url}",e))).json();if(m.thumbnail_url){const h=m.thumbnail_url.replace("height=100","height=480").replace("-d_295x166","-d_640");r(h),u[e]=h}},I=e=>{(e.key==="Enter"||e.key===" ")&&n?.(e)},v=e=>{n?.(e)},l=a.isValidElement(t),y={display:"flex",alignItems:"center",justifyContent:"center"},c={preview:{width:"100%",height:"100%",backgroundImage:f&&!l?`url(${f})`:void 0,backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",...y},shadow:{background:"radial-gradient(rgb(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%)",borderRadius:d,width:d,height:d,position:l?"absolute":void 0,...y},playIcon:{borderStyle:"solid",borderWidth:"16px 0 16px 26px",borderColor:"transparent transparent transparent white",marginLeft:"7px"}},w=a.createElement("div",{style:c.shadow,className:"react-player__shadow"},a.createElement("div",{style:c.playIcon,className:"react-player__play-icon"}));return a.createElement("div",{style:c.preview,className:"react-player__preview",tabIndex:_,onClick:v,onKeyDown:I,...p?{"aria-label":p}:{}},l?t:null,b||w)};var C=k;export{C as default};
//# sourceMappingURL=Preview-CKTJqKe-.js.map
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-n0JxE1Mv.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.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(()=>{$(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(q,{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-CaWv689i.js.map
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-5EWtcenj.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.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(()=>{$(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(q,{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-DN7XBkOe.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
function u(i){return class extends i{_playedRanges=[];_currentPlayedRange=null;_RANGE_EPSILON=.5;_seeking=!1;connectedCallback(){super.connectedCallback&&super.connectedCallback(),this.addEventListener("play",()=>this.onPlaybackStart({time:this.currentTime})),this.addEventListener("pause",()=>this.onPlaybackStop({time:this.currentTime})),this.addEventListener("ended",()=>this.onPlaybackStop({time:this.currentTime})),this.addEventListener("seeking",()=>this.onSeeking()),this.addEventListener("seeked",()=>this.onSeeked({time:this.currentTime}))}onPlaybackStart(e={}){this._seeking=!1;const{time:t}=e,n=typeof t=="number"?t:this.currentTime;this._currentPlayedRange||(this._currentPlayedRange={start:n,end:n})}onSeeking(){this._seeking=!0,this._commitCurrentRange()}onSeeked(e={}){this._seeking=!1;const{time:t}=e,n=typeof t=="number"?t:this.currentTime;this._currentPlayedRange={start:n,end:n}}onPlaybackStop(e={}){const{time:t}=e,n=typeof t=="number"?t:this.currentTime;this._commitCurrentRange(n)}_commitCurrentRange(e){if(!this._currentPlayedRange)return;typeof e=="number"&&(this._currentPlayedRange.end=e);const{start:t,end:n}=this._currentPlayedRange;this._currentPlayedRange=null,this.addPlayedRange(t,n)}addPlayedRange(e,t){if(e>=t)return;const n=this._RANGE_EPSILON,c=[...this._playedRanges,{start:e,end:t}];c.sort((a,s)=>a.start-s.start);const r=[];for(const a of c){if(!r.length){r.push({...a});continue}const s=r[r.length-1];a.start<=s.end+n?(s.start=Math.min(s.start,a.start),s.end=Math.max(s.end,a.end)):r.push({...a})}this._playedRanges=r}get played(){const e=this.currentTime;return!this.paused&&!this._currentPlayedRange&&typeof e=="number"&&(this._currentPlayedRange={start:e,end:e}),this._currentPlayedRange&&typeof e=="number"&&(e>this._currentPlayedRange.end&&(this._currentPlayedRange.end=e),this.addPlayedRange(this._currentPlayedRange.start,this._currentPlayedRange.end)),this._playedRanges.length?l(this._playedRanges.map(t=>[t.start,t.end])):l([[0,0]])}}}function l(i){return Object.defineProperties(i,{start:{value:d=>i[d][0]},end:{value:d=>i[d][1]}}),i}export{u as M};
//# sourceMappingURL=index-B4nclofW.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
import{R as W}from"./index-5EWtcenj.js";const $=["abort","canplay","canplaythrough","durationchange","emptied","encrypted","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting","waitingforkey","resize","enterpictureinpicture","leavepictureinpicture","webkitbeginfullscreen","webkitendfullscreen","webkitpresentationmodechanged"],A=globalThis.document?.createElement("template");A&&(A.innerHTML=`
<style>
:host {
display: inline-block;
line-height: 0;
}
video,
audio {
max-width: 100%;
max-height: 100%;
min-width: 100%;
min-height: 100%;
}
</style>
<slot></slot>
`);const N=(i,{tag:t,is:s})=>{const d=globalThis.document?.createElement(t,{is:s}),f=d?S(d):[];return class g extends i{static Events=$;static template=A;static skipAttributes=[];static#t;static get observedAttributes(){return g.#l(),[...d?.constructor?.observedAttributes??[],"autopictureinpicture","disablepictureinpicture","disableremoteplayback","autoplay","controls","controlslist","crossorigin","loop","muted","playsinline","poster","preload","src"]}static#l(){if(this.#t)return;this.#t=!0;const e=new Set(this.observedAttributes);e.delete("muted");for(let o of f){if(o in this.prototype)continue;if(typeof d[o]=="function")this.prototype[o]=function(...r){this.#e();const a=()=>this.call?this.call(o,...r):this.nativeEl[o].apply(this.nativeEl,r);return this.loadComplete&&!this.isLoaded?this.loadComplete.then(a):a()};else{let r={get(){this.#e();let a=o.toLowerCase();if(e.has(a)){const p=this.getAttribute(a);return p===null?!1:p===""?!0:p}return this.get?.(o)??this.nativeEl?.[o]??this.#s[o]}};o!==o.toUpperCase()&&(r.set=async function(a){this.#e();let p=o.toLowerCase();if(e.has(p)){a===!0||a===!1||a==null?this.toggleAttribute(p,!!a):this.setAttribute(p,a);return}if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.set){this.set(o,a);return}this.nativeEl[o]=a}),Object.defineProperty(this.prototype,o,r)}}}#o;#n;#r=!1;#i=!1;#a;#s;constructor(){super(),this.shadowRoot||(this.attachShadow({mode:"open"}),this.shadowRoot.append(this.constructor.template.content.cloneNode(!0))),this.load!==g.prototype.load&&(this.loadComplete=new O)}get loadComplete(){return this.#n}set loadComplete(e){this.#i=!1,this.#n=e,e?.then(()=>{this.#i=!0})}get isLoaded(){return this.#i}get nativeEl(){return this.#a??this.shadowRoot.querySelector(t)??this.querySelector(t)}set nativeEl(e){this.#a=e}get defaultMuted(){return this.hasAttribute("muted")}set defaultMuted(e){this.toggleAttribute("muted",!!e)}get src(){return this.getAttribute("src")}set src(e){this.setAttribute("src",`${e}`)}get preload(){return this.getAttribute("preload")??this.nativeEl?.preload}set preload(e){this.setAttribute("preload",`${e}`)}async#e(){if(this.#o)return;this.#o=!0,this.#u(),this.#c();for(let u of f)this.#d(u);const e=new Map,o=this.shadowRoot.querySelector("slot:not([name])");o?.addEventListener("slotchange",()=>{const u=new Map(e);o.assignedElements().filter(r=>["track","source"].includes(r.localName)).forEach(async r=>{u.delete(r);let a=e.get(r);a||(a=r.cloneNode(),e.set(r,a)),this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.nativeEl.append?.(a)}),u.forEach(r=>r.remove())});for(let u of this.constructor.Events)this.shadowRoot.addEventListener?.(u,r=>{r.target===this.nativeEl&&this.dispatchEvent(new CustomEvent(r.type,{detail:r.detail}))},!0)}#d(e){if(Object.prototype.hasOwnProperty.call(this,e)){const o=this[e];delete this[e],this[e]=o}}#u(){const e=document.createElement(t,{is:s});e.muted=this.hasAttribute("muted");for(let{name:o,value:u}of this.attributes)e.setAttribute(o,u);this.#s={};for(let o of S(e))this.#s[o]=e[o];e.removeAttribute("src"),e.load()}async#c(){if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,!this.nativeEl){const e=document.createElement(t,{is:s});e.part=t,this.shadowRoot.append(e)}this.nativeEl.muted=this.hasAttribute("muted")}attributeChangedCallback(e,o,u){this.#e(),e==="src"&&this.load!==g.prototype.load&&this.#h(),this.#p(e,o,u)}async#h(){this.#r&&(this.loadComplete=new O),this.#r=!0,await Promise.resolve(),await this.load(),this.loadComplete?.resolve(),await this.loadComplete}async#p(e,o,u){this.loadComplete&&!this.isLoaded&&await this.loadComplete,!["id","class",...this.constructor.skipAttributes].includes(e)&&(u===null?this.nativeEl.removeAttribute?.(e):this.nativeEl.setAttribute?.(e,u))}connectedCallback(){this.#e()}}};function S(i){let t=[];for(let s=Object.getPrototypeOf(i);s&&s!==HTMLElement.prototype;s=Object.getPrototypeOf(s))t.push(...Object.getOwnPropertyNames(s));return t}class O extends Promise{constructor(t=()=>{}){let s,d;super((f,c)=>{t(f,c),s=f,d=c}),this.resolve=s,this.reject=d}}const B=globalThis.document?N(HTMLElement,{tag:"video"}):class{};globalThis.document&&N(HTMLElement,{tag:"audio"});var H,R;const L=(H=globalThis.document)==null?void 0:H.createElement("template");L&&(L.innerHTML=`
<div class="wistia_embed"></div>
`);const P=(R=globalThis.document)==null?void 0:R.createElement("template");P&&(P.innerHTML=`
<style>
:host {
display: inline-block;
min-width: 300px;
min-height: 150px;
position: relative;
}
::slotted(.wistia_embed) {
position: absolute;
width: 100%;
height: 100%;
}
</style>
<slot></slot>
`);function D(i,t){if(Object.hasOwn(i,t)){const s=i[t];delete i[t],i[t]=s}}class q extends B{static template=P;static skipAttributes=["src"];#t=null;constructor(){super(),D(this,"config")}get config(){return this.#t}set config(t){this.#t=t}get nativeEl(){var t;return((t=this.api)==null?void 0:t.elem())??this.querySelector("video")}async load(){var t;if((t=this.querySelector(".wistia_embed"))==null||t.remove(),!this.src)return;await new Promise(o=>setTimeout(o,50));const s=/(?:wistia\.com|wi\.st)\/(?:medias|embed)\/(.*)$/i,d=this.src.match(s)[1],f={autoPlay:this.autoplay,silentAutoPlay:this.autoplay?"allow":void 0,preload:this.preload??"metadata",playsinline:this.playsInline,endVideoBehavior:this.loop?"loop":void 0,chromeless:!this.controls,playButton:this.controls,muted:this.defaultMuted,...this.#t??{}};this.append(L.content.cloneNode(!0));const c=this.querySelector(".wistia_embed");c.id||(c.id=z(d)),c.classList.add(`wistia_async_${d}`),await F("https://fast.wistia.com/assets/external/E-v1.js","Wistia"),this.api=await new Promise(o=>{globalThis._wq.push({id:c.id,onReady:o,options:f})})}async attributeChangedCallback(t,s,d){if(t==="controls"){switch(await this.loadComplete,t){case"controls":this.api.bigPlayButtonEnabled(this.controls),this.controls?this.api.releaseChromeless():this.api.requestChromeless();break}return}super.attributeChangedCallback(t,s,d)}get duration(){var t;return(t=this.api)==null?void 0:t.duration()}async play(){return await this.loadComplete,this.api.play(),new Promise(t=>this.addEventListener("playing",t))}}const T={};async function F(i,t){if(T[i])return T[i];if(self[t])return self[t];const s=new Promise((d,f)=>{const c=document.createElement("script");c.defer=!0,c.src=i,c.onload=()=>d(self[t]),c.onerror=f,document.head.append(c)});return T[i]=s,s}let U=0;function z(i){const t=++U;return`${i}${t}`}globalThis.customElements&&!globalThis.customElements.get("wistia-video")&&globalThis.customElements.define("wistia-video",q);var V=q,G=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),J={className:"class",htmlFor:"for"};function I(i){return i.toLowerCase()}function j(i){if(typeof i=="boolean")return i?"":void 0;if(typeof i!="function"&&!(typeof i=="object"&&i!==null))return i}function K({react:i,tagName:t,elementClass:s,events:d,displayName:f,defaultProps:c,toAttributeName:e=I,toAttributeValue:o=j}){const u=Number.parseInt(i.version)>=19,r=i.forwardRef((a,p)=>{var M,k;const m=i.useRef(null),E=i.useRef(new Map),C={},_={},v={},w={};for(const[n,l]of Object.entries(a)){if(G.has(n)){v[n]=l;continue}const h=e(J[n]??n);if(s.prototype&&n in s.prototype&&!(n in(((M=globalThis.HTMLElement)==null?void 0:M.prototype)??{}))&&!((k=s.observedAttributes)!=null&&k.some(b=>b===h))){w[n]=l;continue}if(n.startsWith("on")){C[n]=l;continue}const y=o(l);if(h&&y!=null&&(_[h]=String(y),u||(v[h]=y)),h&&u){const b=j(l);y!==b?v[h]=y:v[h]=l}}if(typeof window<"u"){for(const n in C){const l=C[n],h=n.endsWith("Capture"),y=(d?.[n]??n.slice(2).toLowerCase()).slice(0,h?-7:void 0);i.useLayoutEffect(()=>{const b=m?.current;if(!(!b||typeof l!="function"))return b.addEventListener(y,l,h),()=>{b.removeEventListener(y,l,h)}},[m?.current,l])}i.useLayoutEffect(()=>{if(m.current===null)return;const n=new Map;for(const l in w)x(m.current,l,w[l]),E.current.delete(l),n.set(l,w[l]);for(const[l,h]of E.current)x(m.current,l,void 0);E.current=n})}if(typeof window>"u"&&s?.getTemplateHTML&&s?.shadowRootOptions){const{mode:n,delegatesFocus:l}=s.shadowRootOptions,h=i.createElement("template",{shadowrootmode:n,shadowrootdelegatesfocus:l,dangerouslySetInnerHTML:{__html:s.getTemplateHTML(_,a)},key:"ce-la-react-ssr-template-shadow-root"});v.children=[h,v.children]}return i.createElement(t,{...c,...v,ref:i.useCallback(n=>{m.current=n,typeof p=="function"?p(n):p!==null&&(p.current=n)},[p])},v.children)});return r.displayName=f??s.name,r}function x(i,t,s){var d;i[t]=s,s==null&&t in(((d=globalThis.HTMLElement)==null?void 0:d.prototype)??{})&&i.removeAttribute(t)}var X=K({react:W,tagName:"wistia-video",elementClass:V,toAttributeName(i){return i==="muted"?"":i==="defaultMuted"?"muted":I(i)}});export{X as default};
//# sourceMappingURL=react-DxdYv-AY.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
import{_ as N,R as A}from"./index-5EWtcenj.js";import{M as D,C as S}from"./mixin-BY5xW4uI.js";class P extends D(S){static shadowRootOptions={...S.shadowRootOptions};static getTemplateHTML=i=>{const{src:n,...o}=i;return S.getTemplateHTML(o)};#t;attributeChangedCallback(i,n,o){i!=="src"&&super.attributeChangedCallback(i,n,o),i==="src"&&n!=o&&this.load()}async _initThumbnails(i){const n=async(d,m)=>{const v=[],g=i.timescale||1,h=i.startNumber||1,E=i.presentationTimeOffset?i.presentationTimeOffset/g:0,_=i.segmentDuration;for(let l=0;l<d;l++){const T=C({thIndex:l,thduration:m,ttiles:d,tduration:_,startNumber:h,pto:E}),w=T+m,M=new Promise((f,y)=>{this.api.provideThumbnail(T,({url:a,width:s,height:c,x:p,y:b})=>{try{const R=new VTTCue(T,w,`${a}#xywh=${p},${b},${s},${c}`);f(R)}catch(R){y(R)}})});v.push(M)}return await Promise.all(v).catch(l=>console.error("Error processing thumbnails",l))},{totalThumbnails:o,thumbnailDuration:e}=$(i),u=await n(o,e);let r=this.nativeEl.querySelector('track[label="thumbnails"]');if(!r){r=O(),this.nativeEl.appendChild(r);const d=x(u);r.src=d,r.dispatchEvent(new Event("change"))}}async load(){if(this.#t){this.api.attachSource(this.src);return}this.#t=!0;const i=await N(()=>import("./dash.all.min-C9gxBrL1.js"),[]);this.api=i.MediaPlayer().create(),this.api.initialize(this.nativeEl,this.src,this.autoplay),this.api.on(i.MediaPlayer.events.STREAM_INITIALIZED,()=>{const n=this.api.getRepresentationsByType("video");let o=this.videoTracks.getTrackById("main");o||(o=this.addVideoTrack("main"),o.id="main",o.selected=!0),n.forEach(e=>{const u=e.bandwidth??e.bitrate??(Number.isFinite(e.bitrateInKbit)?e.bitrateInKbit*1e3:void 0),r=o.addRendition(e.id,e.width,e.height,e.mimeType??e.codec,u);r.id=e.id}),this.videoRenditions.addEventListener("change",()=>{const e=this.videoRenditions[this.videoRenditions.selectedIndex];e?.id?(this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),this.api.setRepresentationForTypeById("video",e.id,!0)):this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}})}),this.api.isDynamic()||this.api.getRepresentationsByType("image").forEach(async(u,r)=>{r>0||this._initThumbnails(u)})})}}function $(t){var i,n;const o=t.essentialProperties[0],[e,u]=o.value.split("x").map(Number),r=e*u,d=((n=(i=t.adaptation)==null?void 0:i.period)==null?void 0:n.duration)||null,m=t.segmentDuration,v=t.timescale||1,h=m/v/r;return{totalThumbnails:d!=null?Math.ceil(d/h):Math.ceil(m/h),thumbnailDuration:h}}function C({thIndex:t,tduration:i,thduration:n,ttiles:o,startNumber:e,pto:u}){const r=Math.floor(t/o)+e,d=t%o+1,m=(r-1)*i-u,v=(d-1)*n;return m+v}function O(){const t=document.createElement("track");return t.kind="metadata",t.label="thumbnails",t.srclang="en",t.mode="hidden",t.default=!0,t}function x(t){let i=`WEBVTT
`;for(const e of t)i+=`${o(e.startTime)} --> ${o(e.endTime)}
`,i+=`${e.text}
`;const n=new Blob([i],{type:"text/vtt"});return URL.createObjectURL(n);function o(e){const u=String(Math.floor(e/3600)).padStart(2,"0"),r=String(Math.floor(e%3600/60)).padStart(2,"0"),d=(e%60).toFixed(3).padStart(6,"0");return`${u}:${r}:${d}`}}globalThis.customElements&&!globalThis.customElements.get("dash-video")&&globalThis.customElements.define("dash-video",P);var B=P,H=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),F={className:"class",htmlFor:"for"};function I(t){return t.toLowerCase()}function k(t){if(typeof t=="boolean")return t?"":void 0;if(typeof t!="function"&&!(typeof t=="object"&&t!==null))return t}function W({react:t,tagName:i,elementClass:n,events:o,displayName:e,defaultProps:u,toAttributeName:r=I,toAttributeValue:d=k}){const m=Number.parseInt(t.version)>=19,v=t.forwardRef((g,h)=>{var E,_;const l=t.useRef(null),T=t.useRef(new Map),w={},M={},f={},y={};for(const[a,s]of Object.entries(g)){if(H.has(a)){f[a]=s;continue}const c=r(F[a]??a);if(n.prototype&&a in n.prototype&&!(a in(((E=globalThis.HTMLElement)==null?void 0:E.prototype)??{}))&&!((_=n.observedAttributes)!=null&&_.some(b=>b===c))){y[a]=s;continue}if(a.startsWith("on")){w[a]=s;continue}const p=d(s);if(c&&p!=null&&(M[c]=String(p),m||(f[c]=p)),c&&m){const b=k(s);p!==b?f[c]=p:f[c]=s}}if(typeof window<"u"){for(const a in w){const s=w[a],c=a.endsWith("Capture"),p=(o?.[a]??a.slice(2).toLowerCase()).slice(0,c?-7:void 0);t.useLayoutEffect(()=>{const b=l?.current;if(!(!b||typeof s!="function"))return b.addEventListener(p,s,c),()=>{b.removeEventListener(p,s,c)}},[l?.current,s])}t.useLayoutEffect(()=>{if(l.current===null)return;const a=new Map;for(const s in y)L(l.current,s,y[s]),T.current.delete(s),a.set(s,y[s]);for(const[s,c]of T.current)L(l.current,s,void 0);T.current=a})}if(typeof window>"u"&&n?.getTemplateHTML&&n?.shadowRootOptions){const{mode:a,delegatesFocus:s}=n.shadowRootOptions,c=t.createElement("template",{shadowrootmode:a,shadowrootdelegatesfocus:s,dangerouslySetInnerHTML:{__html:n.getTemplateHTML(M,g)},key:"ce-la-react-ssr-template-shadow-root"});f.children=[c,f.children]}return t.createElement(i,{...u,...f,ref:t.useCallback(a=>{l.current=a,typeof h=="function"?h(a):h!==null&&(h.current=a)},[h])},f.children)});return v.displayName=e??n.name,v}function L(t,i,n){var o;t[i]=n,n==null&&i in(((o=globalThis.HTMLElement)==null?void 0:o.prototype)??{})&&t.removeAttribute(i)}var V=W({react:A,tagName:"dash-video",elementClass:B,toAttributeName(t){return t==="muted"?"":t==="defaultMuted"?"muted":I(t)}});export{V as default};
//# sourceMappingURL=react-gdx3v4jU.js.map
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-n0JxE1Mv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xCRVLGjq.css">
<script type="module" crossorigin src="/assets/index-5EWtcenj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-3oGy4XGJ.css">
</head>
<body>
<div id="root"></div>
@@ -173,6 +173,8 @@ test('service definitions generate public feature paths without a separate regis
{ key: 'homeAssistant', path: ['homeAssistant', 'enabled'] },
{ key: 'neato', path: ['homeAssistant', 'neato', 'enabled'] },
{ key: 'lift', path: ['homeAssistant', 'lift', 'enabled'] },
// Activities remain independently configurable from the shared connection.
{ key: 'homeAssistantActivities', path: ['homeAssistantActivities', 'enabled'] },
{ key: 'roomCameras', path: ['roomCameras', 'enabled'] },
{ key: 'ptzCamera', path: ['ptzCamera', 'enabled'] },
{ key: 'kinect', path: ['kinect', 'enabled'] },
+3
View File
@@ -12,6 +12,7 @@ const bandwidthSavings = require('../helpers/bandwidthSavings.configuration');
const audioForward = require('../services/audioForwardService/configuration');
const audioLevels = require('../services/audioLevelsService/configuration');
const homeAssistant = require('../services/homeAssistantService/configuration');
const homeAssistantActivities = require('../services/homeAssistantActivitiesService/configuration');
const roomCameras = require('../services/roomCameraService/configuration');
const ptzCamera = require('../services/ptzCameraService/configuration');
const kinect = require('../services/kinectService/configuration');
@@ -41,6 +42,8 @@ const definitions = [
audioForward,
audioLevels,
homeAssistant,
// Activities own a separate catalog even though the HA connection is shared.
homeAssistantActivities,
roomCameras,
ptzCamera,
kinect,
@@ -44,7 +44,7 @@ module.exports = {
goal: 400,
async run(ctx) {
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 60 seconds.' });
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room lights strobing for 60 seconds.' });
},
async recover(ctx, effect) {
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
+12 -2
View File
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('../modeManager');
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
const roverManager = require('../roverManager');
const { getOperatingMode } = require('../operatingModeService');
const { compareRoversForAssignment } = require('./roverRanking');
const socketRefs = new Map(); // socketId -> socket
@@ -131,7 +132,7 @@ roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
});
function assignSocket(socket, options = {}) {
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
if (!socket || getOperatingMode(socket) !== 'rover' || isAdmin(socket) || getRole(socket) !== 'user') {
return;
}
// avoid double assignment
@@ -311,13 +312,22 @@ function describeAssignment(socketId) {
module.exports = {
assignmentEvents,
resumeAssignment: assignSocket,
releaseForOperatingMode: (socket) => {
// Include direct admin control as well as automatic assignment ownership.
const roverIds = new Set(roverManager.getRoversForSocket(socket.id));
const assigned = assignments.get(socket.id);
if (assigned) roverIds.add(assigned);
waiting.delete(socket.id);
roverIds.forEach((roverId) => forceRelease(roverId, socket.id));
},
describeAssignment,
forceRelease,
forceReleaseWithNotice,
rerollAssignments,
getAssignedRover: (socketId) => assignments.get(socketId) || null,
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
if (!socket || !roverId) return;
if (!socket || !roverId || getOperatingMode(socket) !== 'rover') return;
const previous = assignments.get(socket.id);
if (previous && previous !== roverId && releasePrevious) {
roverManager.releaseControl(previous, socket);
@@ -2,20 +2,14 @@
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
const { v4: uuidv4 } = require('uuid');
const io = require('../../globals/io');
const roverManager = require('../roverManager');
const { getRole } = require('../roleService');
const { getOperatingMode } = require('../operatingModeService');
const { describeAssignment } = require('../assignmentService');
const { getNickname } = require('../nicknameService');
const ptzCameraService = require('../ptzCameraService');
function resolvePtzChatTarget(socketId) {
return ptzCameraService.getChatTargetForSocket(socketId) || null;
}
function resolveRoverId(socketId) {
const ptzTarget = resolvePtzChatTarget(socketId);
if (ptzTarget?.roverId) return ptzTarget.roverId;
const primary = roverManager.getPrimaryRoverForSocket(socketId);
if (primary) return primary;
const assignment = describeAssignment(socketId);
@@ -24,54 +18,22 @@ function resolveRoverId(socketId) {
function resolveRoverColor(roverId) {
if (!roverId) return null;
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
return ptzCameraService.getPublicState()?.color || null;
}
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.color || null;
}
function resolveRoverName(roverId) {
if (!roverId) return null;
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
return ptzCameraService.getPublicState()?.name || null;
}
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.name || null;
}
function isPtzChatTargetId(roverId) {
/*
PTZ is intentionally treated as a virtual rover for chat identity only. It
does not live in roverManager.rovers because movement, video authorization,
and queue ownership are PTZ-service concerns, but chat needs one stable
"rover-like" id so the existing web UI, Discord bridge, and AI transcript
code can all render the same badge without learning PTZ internals.
*/
return Boolean(roverId) && String(roverId) === ptzCameraService.PTZ_CAMERA_ID;
}
function isPublicChatTargetId(roverId, socket = null) {
if (!roverId) return false;
/*
Normal rovers remain governed by the existing replay visibility rule, which
is also the rule chat historically used to avoid exposing closed private
rover activity. PTZ gets an explicit allow-list entry here because it is a
public chat target that deliberately pretends to be a rover, even though it
is not a roverManager record.
*/
if (isPtzChatTargetId(roverId)) return true;
return roverManager.canReplayRoverId(roverId, socket) === true;
return Boolean(roverId) && roverManager.canReplayRoverId(roverId, socket) === true;
}
function isPrivateClosedRoverId(roverId) {
if (!roverId) return false;
/*
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
but it is not a private rover. Let PTZ-badged messages broadcast normally
instead of falling into the closed-private rover path for unknown ids.
*/
return !isPublicChatTargetId(roverId);
return Boolean(roverId) && !isPublicChatTargetId(roverId);
}
function normalizeProfileImageUrl(value) {
@@ -170,6 +132,8 @@ function buildMessage(socket, text, meta = {}) {
socketId: socket?.id || null,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
operatingMode: socket ? getOperatingMode(socket) : null,
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
roverId,
roverName,
roverColor,
@@ -215,6 +179,8 @@ function buildTypingPayload(socket, meta = {}) {
socketId,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
operatingMode: socket ? getOperatingMode(socket) : null,
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
roverId,
roverName,
roverColor,
@@ -231,7 +197,6 @@ function buildTypingPayload(socket, meta = {}) {
module.exports = {
resolveRoverId,
isPtzChatTargetId,
isPublicChatTargetId,
isPrivateClosedRoverId,
buildRoverCtxSnapshot,
@@ -19,13 +19,6 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
function playTypingNote(roverId, note, socketId) {
if (!roverId) return;
/*
PTZ borrows the roverId field for chat badges, but it has no rover command
channel. Skipping the song command here keeps PTZ chat from producing noisy
"unknown rover" command attempts while still allowing the message itself to
behave like rover chat everywhere else.
*/
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
try {
issueCommand(roverId, {
type: 'song',
@@ -90,8 +83,8 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
}
function maybeSpeak(socket, message, ttsOptions) {
if (!ttsOptions || !message?.roverId) return;
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
if (!ttsOptions || !message) return;
if (message.operatingMode === 'ptz') {
/*
PTZ has no rover websocket, but it does have a real speaker behind the
Reolink/neolink path. Keep PTZ routing here so chat remains the single
@@ -107,6 +100,7 @@ function maybeSpeak(socket, message, ttsOptions) {
});
return;
}
if (!message.roverId) return;
const record = roverManager.rovers.get(message.roverId);
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
if (!ttsEnabled) return;
@@ -10,6 +10,8 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
// Both command transports use the same activity lock owner.
const homeAssistantActivitiesService = require('../homeAssistantActivitiesService');
const greenModeService = require('../greenModeService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
@@ -177,6 +179,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
// lights lock/unlock` from becoming transport-specific, and it preserves
// the existing session update path for all connected browsers.
homeAssistantService,
homeAssistantActivitiesService,
greenModeService,
liftService,
neatoService,
@@ -6,12 +6,14 @@ const { MessageFlags } = require('discord.js');
function createChannelIO({ client, logger, sanitizeMentions }) {
const channelCache = new Map();
const typingMessageCache = new Map();
let stopped = false;
async function fetchChannel(id) {
if (!id) return null;
if (stopped || !client.isReady() || !id) return null;
if (channelCache.has(id)) return channelCache.get(id);
try {
const channel = await client.channels.fetch(id);
if (stopped) return null;
if (channel) {
channelCache.set(id, channel);
return channel;
@@ -68,6 +70,7 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
const content = `-# *${username} is typing...*`;
try {
const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]});
if (stopped) return;
const timeoutId = setTimeout(() => {
clearTypingMessage(entry.guildId, typingId);
}, 20000);
@@ -78,6 +81,12 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
}
return {
stop() {
stopped = true;
for (const record of typingMessageCache.values()) clearTimeout(record.timeoutId);
typingMessageCache.clear();
channelCache.clear();
},
fetchChannel,
sendToChannel,
clearTypingMessage,
@@ -18,6 +18,7 @@ module.exports = {
adminAlerts: '123456789012345678',
replay: '123456789012345678',
humanAlerts: '123456789012345678',
liveStatus: '',
},
roles: {
stalkerPing: '123456789012345678',
@@ -31,6 +32,7 @@ module.exports = {
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], 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.', examples: ['123456789012345678'], maxLength: 100 }),
channels: strictObject({
liveStatus: string({ title: 'Live status', description: 'Dedicated live fleet report channel ID. The bot deletes ALL other messages here. Requires View Channel, Read Message History, Send Messages, and Manage Messages. Leave empty to disable.', examples: ['123456789012345678'], maxLength: 100 }),
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }),
@@ -0,0 +1,94 @@
// Owns connection replacement and recovery from failed initial logins.
function createConnectionManager({ createRuntime, logger }) {
let desired = { enabled: false, token: '' };
let revision = 0;
let runtime = null;
let queue = Promise.resolve();
let retryTimer = null;
let retryDelay = 5000;
function enqueue(operation) {
const pending = queue.then(operation);
// Report failures without poisoning subsequent configuration changes.
queue = pending.catch((err) => logger.error('Discord connection update failed', err.message));
return pending;
}
async function stop() {
const previous = runtime;
runtime = null;
if (!previous) return;
previous.stop();
// discord.js cannot cancel login while gateway discovery is pending.
// Let that attempt settle before destroying it, otherwise it can open a
// socket after destroy() has already run and leave an orphan connection.
await previous.login;
await previous.client.destroy();
}
function scheduleRetry(version) {
if (version !== revision || !desired.enabled) return;
const delay = retryDelay;
retryDelay = Math.min(retryDelay * 2, 60000);
logger.warn('Retrying Discord login', { delayMs: delay });
retryTimer = setTimeout(() => {
retryTimer = null;
enqueue(() => start(version));
}, delay);
retryTimer.unref?.();
}
async function start(version) {
if (version !== revision || !desired.enabled) return;
await stop();
if (version !== revision || !desired.enabled) return;
const current = createRuntime();
runtime = current;
const { client } = current;
client.on('error', (err) => logger.error('Discord client error', err.message));
client.on('shardError', (err, shardId) => {
logger.warn('Discord connection error', { shardId, error: err.message });
});
client.on('shardDisconnect', (event, shardId) => {
logger.warn('Discord disconnected', { shardId, code: event.code });
});
client.on('clientReady', () => { retryDelay = 5000; });
// Login must not block configuration saves while the network is down.
// Each attempt has its own client, so stale login completion/cleanup cannot
// clear the credentials or stop the connection of its replacement.
current.login = client.login(desired.token).catch((err) => {
if (version !== revision || runtime !== current) return;
logger.error('Discord login failed', err.message);
enqueue(async () => {
if (version !== revision || runtime !== current) return;
await stop();
// Credentials require an operator edit; retry network/service failures.
if (err.code === 'TokenInvalid' || err.status === 401) return;
scheduleRetry(version);
});
});
}
function configure({ enabled, token }) {
const next = { enabled: Boolean(enabled), token };
if (next.enabled === desired.enabled && next.token === desired.token) return queue;
desired = next;
const version = ++revision;
clearTimeout(retryTimer);
retryTimer = null;
retryDelay = 5000;
return enqueue(async () => {
if (version !== revision) return;
if (desired.enabled) await start(version);
else await stop();
});
}
return {
configure,
refresh() { runtime?.refresh(); },
};
}
module.exports = { createConnectionManager };
@@ -0,0 +1,146 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { createConnectionManager } = require('./connection');
const flush = () => new Promise((resolve) => setImmediate(resolve));
function deferred() {
let resolve;
let reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function setup(t, attempts = []) {
t.mock.timers.enable({ apis: ['setTimeout'] });
const runtimes = [];
const logs = [];
const manager = createConnectionManager({
logger: {
warn: (...args) => logs.push(args),
error: (...args) => logs.push(args),
},
createRuntime() {
const attempt = attempts[runtimes.length] || {};
const client = new EventEmitter();
const runtime = {
client,
stopped: false,
destroyed: false,
refresh() {},
stop() { runtime.stopped = true; },
};
client.login = async (token) => {
runtime.token = token;
await attempt.login?.();
};
client.destroy = async () => {
await attempt.destroy?.();
runtime.destroyed = true;
};
runtimes.push(runtime);
return runtime;
},
});
return { manager, runtimes, logs };
}
test('retries a network failure at startup with a fresh client', async (t) => {
const { manager, runtimes } = setup(t, [{ login: () => { throw new Error('getaddrinfo EAI_AGAIN'); } }]);
await manager.configure({ enabled: true, token: 'token' });
await flush();
assert.equal(runtimes[0].stopped, true);
assert.equal(runtimes[0].destroyed, true);
t.mock.timers.tick(4999);
await flush();
assert.equal(runtimes.length, 1);
t.mock.timers.tick(1);
await flush();
assert.equal(runtimes.length, 2);
assert.equal(runtimes[1].token, 'token');
assert.notEqual(runtimes[0].client, runtimes[1].client);
});
test('backs off repeated failures and cancels retries when disabled', async (t) => {
const failed = { login: () => { throw new Error('Network unavailable'); } };
const { manager, runtimes, logs } = setup(t, Array(8).fill(failed));
await manager.configure({ enabled: true, token: 'token' });
await flush();
for (const delay of [5000, 10000, 20000, 40000, 60000]) {
t.mock.timers.tick(delay);
await flush();
}
assert.deepEqual(logs.filter(([message]) => message === 'Retrying Discord login').map(([, data]) => data.delayMs),
[5000, 10000, 20000, 40000, 60000, 60000]);
await manager.configure({ enabled: false, token: 'token' });
t.mock.timers.tick(120000);
await flush();
assert.equal(runtimes.length, 6);
});
test('waits for logout before logging in with a changed token', async (t) => {
const logout = deferred();
const { manager, runtimes } = setup(t, [{ destroy: () => logout.promise }]);
await manager.configure({ enabled: true, token: 'old' });
const change = manager.configure({ enabled: true, token: 'new' });
await flush();
assert.equal(runtimes[0].stopped, true);
assert.equal(runtimes.length, 1);
logout.resolve();
await change;
assert.equal(runtimes[0].destroyed, true);
assert.equal(runtimes[1].token, 'new');
});
test('retires a pending login before replacement and ignores its stale failure', async (t) => {
const login = deferred();
const { manager, runtimes } = setup(t, [{ login: () => login.promise }]);
await manager.configure({ enabled: true, token: 'old' });
const change = manager.configure({ enabled: true, token: 'new' });
await flush();
assert.equal(runtimes[0].stopped, true);
assert.equal(runtimes[0].destroyed, false);
login.reject(new Error('Old connection failed'));
await change;
assert.equal(runtimes[0].destroyed, true);
assert.equal(runtimes[1].token, 'new');
t.mock.timers.tick(120000);
await flush();
assert.equal(runtimes.length, 2);
});
test('re-enabling creates a fresh client; unrelated config edits do not reconnect', async (t) => {
const { manager, runtimes } = setup(t);
await manager.configure({ enabled: true, token: 'token' });
await manager.configure({ enabled: true, token: 'token', channels: { general: 'new-channel' } });
assert.equal(runtimes.length, 1);
await manager.configure({ enabled: false, token: 'token' });
assert.equal(runtimes[0].destroyed, true);
await manager.configure({ enabled: true, token: 'token' });
assert.equal(runtimes.length, 2);
});
test('invalid credentials wait for a token edit instead of retrying', async (t) => {
const { manager, runtimes } = setup(t, [{ login: () => {
throw Object.assign(new Error('Invalid token'), { code: 'TokenInvalid' });
} }]);
await manager.configure({ enabled: true, token: 'bad' });
await flush();
t.mock.timers.tick(120000);
await flush();
assert.equal(runtimes.length, 1);
await manager.configure({ enabled: true, token: 'fixed' });
assert.equal(runtimes[1].token, 'fixed');
});
test('client and gateway errors are logged without throwing', async (t) => {
const { manager, runtimes, logs } = setup(t);
await manager.configure({ enabled: true, token: 'token' });
runtimes[0].client.emit('error', new Error('Client failure'));
runtimes[0].client.emit('shardError', new Error('Gateway failure'), 0);
runtimes[0].client.emit('shardDisconnect', { code: 1006 }, 0);
assert.deepEqual(logs.map(([message]) => message), [
'Discord client error', 'Discord connection error', 'Discord disconnected',
]);
});
+295 -277
View File
@@ -22,11 +22,13 @@ const { MODES, getMode, setMode } = require('../modeManager');
const { sendExternalMessage, sendExternalTyping } = require('../chatService');
const { commandReplyToText } = require('../chatService/commandResultFormatter');
const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2');
const { getActiveDrivers } = require('../turnService');
const { getNickname } = require('../nicknameService');
const { getActiveDrivers, turnEvents } = require('../turnService');
const { getNickname, nicknameEvents } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
// Both command transports use the same activity lock owner.
const homeAssistantActivitiesService = require('../homeAssistantActivitiesService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const {
@@ -65,12 +67,14 @@ const {
} = require('../privateRoverAccessRequestService');
const { subscribe } = require('../eventBus');
const { createPresenceManager } = require('./presence');
const { createConnectionManager } = require('./connection');
const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('../operatorCommandService');
const greenModeService = require('../greenModeService');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations');
const { createFleetDailyReports } = require('./fleetDailyReports');
const { createLiveStatus } = require('./liveStatus');
const fleetReportService = require('../fleetReportService');
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
const {
@@ -84,9 +88,7 @@ const {
buildStatusMessage,
} = require('../replayDeliveryService/workflow');
// Discord helper modules retain references to these objects. Mutating those
// references on configuration application updates command and integration
// behavior without registering a second tree of Discord/event listeners.
// Keep configuration object identities stable for the active runtime's helpers.
const config = structuredClone(loadConfig());
const discordConfig = config.discord || {};
let enabled = Boolean(discordConfig.enabled);
@@ -104,11 +106,6 @@ const intents = [
GatewayIntentBits.MessageContent,
];
const client = new Client({
intents,
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
});
function sanitizeMentions(text) {
if (!text) return '';
return String(text)
@@ -154,299 +151,322 @@ function countReady() {
return { ready, total };
}
const channelIO = createChannelIO({
client,
logger,
sanitizeMentions,
});
// Each connection owns its listeners, timers, and cached Discord objects.
// Rebuilding them prevents a reconnect from retaining a destroyed client.
function createDiscordRuntime() {
const client = new Client({
intents,
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
});
const channelIO = createChannelIO({
client,
logger,
sanitizeMentions,
});
const presence = createPresenceManager({
client,
logger,
getMode,
getGlobalObjective,
countReady,
});
const presence = createPresenceManager({
client,
logger,
getMode,
getGlobalObjective,
countReady,
});
const replayCaption = createReplayCaptionBuilder({
io,
rovers,
getActiveDrivers,
getNickname,
sanitizeMentions,
});
const replayCaption = createReplayCaptionBuilder({
io,
rovers,
getActiveDrivers,
getNickname,
sanitizeMentions,
});
// Discord is the preferred replay host only while this optional feature is
// active. The core replay delivery service owns generation and automatically
// falls back to its local media store when any operation below fails.
registerPreferredDeliveryProvider({
async begin(job) {
const channelId = discordConfig.channels?.replay;
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
const channel = await channelIO.fetchChannel(channelId);
return {
channelId,
progressMessage,
stopTyping: startDiscordTypingLoop(channel, logger, 'web replay delivery'),
};
},
async deliver({ job, context, buffer, usedSources = job.sources, missingSources = [] }) {
const progressMessage = context?.progressMessage;
try {
if (progressMessage?.edit) {
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
// Discord is the preferred replay host only while this optional feature is
// active. The core replay delivery service owns generation and automatically
// falls back to its local media store when any operation below fails.
const unregisterReplayProvider = registerPreferredDeliveryProvider({
async begin(job) {
const channelId = discordConfig.channels?.replay;
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
const channel = await channelIO.fetchChannel(channelId);
return {
channelId,
progressMessage,
stopTyping: startDiscordTypingLoop(channel, logger, 'web replay delivery'),
};
},
async deliver({ job, context, buffer, usedSources = job.sources, missingSources = [] }) {
const progressMessage = context?.progressMessage;
try {
if (progressMessage?.edit) {
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
}
// Every delivery path uses the job creation time, so a Discord upload
// and a server-hosted fallback always expose the same replay filename.
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
if (!uploadMessage) throw new Error('Discord upload did not return a message');
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: firstAttachmentFromMessage(uploadMessage), job });
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
if (progressMessage?.edit) {
// The attachment URL is already durable once Discord returns it. A
// cosmetic progress-edit failure must not trigger a duplicate local
// replay or replace the successful media payload sent to clients.
await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }).catch((err) => {
logger.warn('Discord replay uploaded but progress message update failed', { jobId: job.id, error: err.message });
});
}
return media;
} catch (err) {
err.progressMessage = progressMessage;
throw err;
} finally {
if (context?.stopTyping) context.stopTyping();
}
// Every delivery path uses the job creation time, so a Discord upload
// and a server-hosted fallback always expose the same replay filename.
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
if (!uploadMessage) throw new Error('Discord upload did not return a message');
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: firstAttachmentFromMessage(uploadMessage), job });
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
if (progressMessage?.edit) {
// The attachment URL is already durable once Discord returns it. A
// cosmetic progress-edit failure must not trigger a duplicate local
// replay or replace the successful media payload sent to clients.
await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }).catch((err) => {
logger.warn('Discord replay uploaded but progress message update failed', { jobId: job.id, error: err.message });
},
async completeFallback({ context, media }) {
const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, '');
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
if (context?.progressMessage?.reply) {
await context.progressMessage.reply({
content: `Replay hosted by the rover server: ${publicUrl}`,
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
}
return media;
} catch (err) {
err.progressMessage = progressMessage;
throw err;
} finally {
if (context?.stopTyping) context.stopTyping();
}
},
async completeFallback({ context, media }) {
const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, '');
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
if (context?.progressMessage?.reply) {
await context.progressMessage.reply({
content: `Replay hosted by the rover server: ${publicUrl}`,
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
}
},
});
},
});
const commandDependencies = {
logger,
client,
io,
rovers,
roverManager,
getMode,
MODES,
setMode,
lockRover,
getNickname,
getActiveDrivers,
buildReplayVideo,
getReplaySources,
getDefaultDiscordSources,
validateSources,
tryTriggerReplay,
getGlobalObjective,
setGlobalObjective,
clearGlobalObjective,
getAdminReason,
setAdminReason,
clearAdminReason,
// Room-light lock commands must use the same Home Assistant service instance
// as sockets, HA button triggers, and idle/darkness policies. Passing the
// service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
getGuildConfig,
setGuildConfig,
removeGuildConfig,
normalizeMode,
VALID_MODES,
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
sanitizeMentions,
sendToChannel: channelIO.sendToChannel,
isAdminUser,
isLockdownAdminUser,
discordConfig,
config,
};
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies);
const commandDependencies = {
logger,
client,
io,
rovers,
roverManager,
getMode,
MODES,
setMode,
lockRover,
getNickname,
getActiveDrivers,
buildReplayVideo,
getReplaySources,
getDefaultDiscordSources,
validateSources,
tryTriggerReplay,
getGlobalObjective,
setGlobalObjective,
clearGlobalObjective,
getAdminReason,
setAdminReason,
clearAdminReason,
// Room-light lock commands must use the same Home Assistant service instance
// as sockets, HA button triggers, and idle/darkness policies. Passing the
// service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService,
homeAssistantActivitiesService,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
getGuildConfig,
setGuildConfig,
removeGuildConfig,
normalizeMode,
VALID_MODES,
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
sanitizeMentions,
sendToChannel: channelIO.sendToChannel,
isAdminUser,
isLockdownAdminUser,
discordConfig,
config,
};
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies);
const integrations = createIntegrations({
logger,
client,
config,
discordConfig,
rovers,
roverManager,
getMode,
MODES,
getGlobalObjective,
getActiveDrivers,
getNickname,
subscribe,
sendExternalMessage,
sendExternalTyping,
getGuildConfig,
listGuildConfigs,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
attachPrivateAccessDmMessage,
getPrivateAccessRequestByMessageId,
approvePrivateAccessRequest,
denyPrivateAccessRequest,
getLockdownAdminIds,
isAdminUser,
isLockdownAdminUser,
sendToChannel: channelIO.sendToChannel,
fetchChannel: channelIO.fetchChannel,
clearTypingMessage: channelIO.clearTypingMessage,
sendTypingMessage: channelIO.sendTypingMessage,
schedulePresenceRotation: presence.schedulePresenceRotation,
buildReplayVideo,
sanitizeMentions,
});
const integrationHandlers = integrations.register();
function isTextCommand(content) {
// Both transports share this parser so command detection cannot drift from
// the dispatcher when an installation changes its prefix.
return parseCommandText(content, config).matched;
}
function isBridgeChannelMessage(message) {
if (!message?.guild?.id || !message?.channelId) return false;
const guildConfig = getGuildConfig(message.guild.id);
return Boolean(guildConfig?.channelId && String(message.channelId) === String(guildConfig.channelId));
}
function createBridgeMirroredCommandMessage(message) {
if (!isBridgeChannelMessage(message) || !isTextCommand(message.content)) return message;
const originalReply = message.reply.bind(message);
return Object.assign(Object.create(message), {
reply: async (payload) => {
const sent = await originalReply(payload);
const text = sanitizeMentions(commandReplyToText(payload));
if (text) {
// Discord command replies are mirrored to web chat by the Discord
// command adapter, not by the chat bridge. The bridge continues to
// ignore bot-authored Discord messages, which prevents typing helper
// messages and bot replies from feeding back into chat.
sendExternalMessage({
text,
nickname: client.user?.username || 'Rover bot',
role: 'admin',
roverId: null,
discordGuildId: message.guild.id,
discordGuildName: message.guild.name,
discordGuildIconUrl: message.guild.iconURL?.({ extension: 'png', size: 64 }) || null,
discordChannelId: message.channelId,
discordUserId: client.user?.id || null,
discordUserName: client.user?.username || 'Rover bot',
discordUserAvatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
bot: true,
profileImage: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
});
}
return sent;
},
});
}
client.on('messageCreate', async (message) => {
try {
await integrationHandlers.handleBridgeInbound(message);
const commandMessage = createBridgeMirroredCommandMessage(message);
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
} catch (err) {
logger.warn('Error handling Discord message', err.message);
}
});
let fleetDailyReports = null;
function restartFleetDailyReports() {
fleetDailyReports?.stop();
fleetDailyReports = createFleetDailyReports({
logger,
client,
config,
discordConfig,
fleetConfig: config.fleetReports || {},
fleetReportService,
rovers,
roverManager,
getMode,
MODES,
getGlobalObjective,
getActiveDrivers,
getNickname,
subscribe,
sendExternalMessage,
sendExternalTyping,
getGuildConfig,
listGuildConfigs,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
attachPrivateAccessDmMessage,
getPrivateAccessRequestByMessageId,
approvePrivateAccessRequest,
denyPrivateAccessRequest,
getLockdownAdminIds,
isAdminUser,
isLockdownAdminUser,
sendToChannel: channelIO.sendToChannel,
fetchChannel: channelIO.fetchChannel,
clearTypingMessage: channelIO.clearTypingMessage,
sendTypingMessage: channelIO.sendTypingMessage,
schedulePresenceRotation: presence.schedulePresenceRotation,
buildReplayVideo,
sanitizeMentions,
});
fleetDailyReports.start();
const integrationHandlers = integrations.register();
const liveStatus = createLiveStatus({
client, logger, discordConfig, roverManager, getActiveDrivers, getNickname,
io, fetchChannel: channelIO.fetchChannel, sanitizeMentions,
turnEvents, nicknameEvents,
});
function isTextCommand(content) {
// Both transports share this parser so command detection cannot drift from
// the dispatcher when an installation changes its prefix.
return parseCommandText(content, config).matched;
}
function isBridgeChannelMessage(message) {
if (!message?.guild?.id || !message?.channelId) return false;
const guildConfig = getGuildConfig(message.guild.id);
return Boolean(guildConfig?.channelId && String(message.channelId) === String(guildConfig.channelId));
}
function createBridgeMirroredCommandMessage(message) {
if (!isBridgeChannelMessage(message) || !isTextCommand(message.content)) return message;
const originalReply = message.reply.bind(message);
return Object.assign(Object.create(message), {
reply: async (payload) => {
const sent = await originalReply(payload);
const text = sanitizeMentions(commandReplyToText(payload));
if (text) {
// Discord command replies are mirrored to web chat by the Discord
// command adapter, not by the chat bridge. The bridge continues to
// ignore bot-authored Discord messages, which prevents typing helper
// messages and bot replies from feeding back into chat.
sendExternalMessage({
text,
nickname: client.user?.username || 'Rover bot',
role: 'admin',
roverId: null,
discordGuildId: message.guild.id,
discordGuildName: message.guild.name,
discordGuildIconUrl: message.guild.iconURL?.({ extension: 'png', size: 64 }) || null,
discordChannelId: message.channelId,
discordUserId: client.user?.id || null,
discordUserName: client.user?.username || 'Rover bot',
discordUserAvatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
bot: true,
profileImage: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
});
}
return sent;
},
});
}
client.on('messageCreate', async (message) => {
try {
if (message.channelId === discordConfig.channels?.liveStatus?.trim()) {
liveStatus.update(true);
return;
}
await integrationHandlers.handleBridgeInbound(message);
const commandMessage = createBridgeMirroredCommandMessage(message);
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
} catch (err) {
logger.warn('Error handling Discord message', err.message);
}
});
let fleetDailyReports = null;
function restartFleetDailyReports() {
fleetDailyReports?.stop();
fleetDailyReports = createFleetDailyReports({
logger,
discordConfig,
fleetConfig: config.fleetReports || {},
fleetReportService,
roverManager,
sendToChannel: channelIO.sendToChannel,
});
fleetDailyReports.start();
}
client.on('clientReady', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
restartFleetDailyReports();
liveStatus.start();
});
return {
client,
refresh() {
if (client.isReady()) restartFleetDailyReports();
if (client.isReady()) liveStatus.start();
},
stop() {
for (const event of ['clientReady', 'messageCreate', 'typingStart', 'messageReactionAdd']) {
client.removeAllListeners(event);
}
integrationHandlers.stop();
unregisterReplayProvider();
presence.stop();
liveStatus.stop();
channelIO.stop();
fleetDailyReports?.stop();
},
};
}
client.on('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
restartFleetDailyReports();
});
const connection = createConnectionManager({ createRuntime: createDiscordRuntime, logger });
function replaceObject(target, source = {}) {
Object.keys(target).forEach((key) => delete target[key]);
Object.assign(target, structuredClone(source));
}
function applyDiscordConfig(nextDiscordConfig = {}) {
const wasEnabled = enabled;
const previousToken = discordConfig.token;
async function applyDiscordConfig(nextDiscordConfig = {}) {
replaceObject(discordConfig, nextDiscordConfig);
config.discord = discordConfig;
enabled = Boolean(discordConfig.enabled);
if (!enabled) {
fleetDailyReports?.stop();
fleetDailyReports = null;
if (wasEnabled) client.destroy();
return;
}
if (!wasEnabled || previousToken !== discordConfig.token) {
if (wasEnabled) client.destroy();
// Login health is reported by Discord itself; do not hold the committed
// configuration request open while an external network service connects.
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed after configuration change', err.message);
});
} else if (client.isReady()) {
restartFleetDailyReports();
}
await connection.configure(discordConfig);
connection.refresh();
}
function applySharedConfigSection(section, value) {
config[section] = structuredClone(value);
// Fleet delivery owns a timer derived from both Discord and fleet settings.
// Reconnecting is unnecessary; rebuild only that scheduler when ready.
if (section === 'fleetReports' && client.isReady()) {
restartFleetDailyReports();
if (section === 'fleetReports') {
connection.refresh();
}
}
@@ -455,10 +475,8 @@ registerConfigurationHandler('discord', applyDiscordConfig);
registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value));
});
if (enabled) {
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed', err.message);
});
}
connection.configure(discordConfig).catch((err) => {
logger.error('Discord startup failed', err.message);
});
module.exports = {};
@@ -59,12 +59,6 @@ function createChatBridgeHandlers(deps) {
function handleChatBridgeOutbound(event) {
const payload = event?.payload;
if (!payload) return;
/*
Outbound bridge filtering must use chat visibility, not rover replay
visibility. PTZ deliberately uses roverId: "ptz-camera" so the existing
chat badge path can be reused, but that id is not a roverManager rover and
would be dropped by canReplayRoverId().
*/
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return;
@@ -24,14 +24,8 @@ function formatWebhookUsername(payload) {
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
}
/*
The chat payload already carries the resolved display name for rover-like
targets. Prefer that name so PTZ, which is intentionally pretending to be a
rover in chat, shows up as "PTZ Camera" instead of the internal id
"ptz-camera"; fall back to the id for older payloads or missing metadata.
*/
const roverTagLabel = payload.roverName || payload.roverId;
const roverTag = payload.roverId ? ` [${roverTagLabel}]` : '';
const tagLabel = payload.operatingModeDisplay?.name || payload.roverName || payload.roverId;
const roverTag = tagLabel ? ` [${tagLabel}]` : '';
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
}
@@ -24,6 +24,7 @@ function createIntegrations(deps) {
const userAnnouncements = createUserAnnouncements({ ...deps, sendToChannel, schedulePresenceRotation });
function register() {
let stopped = false;
client.on('typingStart', (typing) => {
chat.handleDiscordTypingStart(typing).catch((err) => logger.warn('Error handling Discord typing', err.message));
});
@@ -33,14 +34,31 @@ function createIntegrations(deps) {
dm.handlePrivateAccessReaction(reaction, user).catch((err) => logger.warn('Error handling private access reaction', err.message));
});
subscribe('*', handleBusEvent);
subscribe('*', userAnnouncements.handleBusEvent);
subscribe('verification.requested', dm.sendVerificationRequestDms);
subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms);
subscribe('chat:message', chat.handleChatBridgeOutbound);
subscribe('chat:typing', chat.handleChatTypingOutbound);
// Only the active, ready connection consumes server events. Retain each
// unsubscribe function so replacement clients never duplicate deliveries.
const unsubscribe = [];
function subscribeWhileReady(type, handler) {
unsubscribe.push(subscribe(type, (event) => {
Promise.resolve().then(() => {
if (!stopped && client.isReady()) return handler(event);
}).catch((err) => {
logger.warn('Error handling Discord integration event', { type, error: err.message });
});
}));
}
subscribeWhileReady('*', handleBusEvent);
subscribeWhileReady('*', userAnnouncements.handleBusEvent);
subscribeWhileReady('verification.requested', dm.sendVerificationRequestDms);
subscribeWhileReady('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms);
subscribeWhileReady('chat:message', chat.handleChatBridgeOutbound);
subscribeWhileReady('chat:typing', chat.handleChatTypingOutbound);
return {
stop() {
stopped = true;
unsubscribe.forEach((remove) => remove());
},
handleBridgeInbound: chat.handleBridgeInbound,
handleBusEvent,
};
@@ -0,0 +1,184 @@
// Owns the single-message live report and the dedicated channel's cleanup.
const { PermissionFlagsBits } = require('discord.js');
const { buildRoverStatusSnapshot } = require('./batteryEmbeds');
function createLiveStatus({ client, logger, discordConfig, roverManager, getActiveDrivers,
getNickname, io, fetchChannel, sanitizeMentions,
turnEvents, nicknameEvents }) {
const COOLDOWN_MS = 2000;
let timer = null;
let sweepTimer = null;
let pending = false;
let cleanupNeeded = false;
let nextUpdateAt = 0;
let listening = false;
const unsubscribe = [];
let stopped = false;
let running = false;
let channelId = null;
let messageId = null;
let lastContent = null;
const clean = (value, limit = 120) => sanitizeMentions(String(value ?? ''))
.replace(/[\r\n]+/g, ' ').slice(0, limit);
function buildReport() {
const drivers = getActiveDrivers();
const roster = roverManager.getRoster();
const lines = [];
for (const rover of roster) {
const snapshot = buildRoverStatusSnapshot(roverManager.rovers.get(rover.id));
const driverId = drivers[rover.id];
const driver = driverId ? clean(getNickname(io.sockets.sockets.get(driverId)) || 'Someone', 32) : null;
const status = snapshot?.docked ? 'docked' : rover.needsHelp ? 'needs help'
: driver ? `${driver} driving` : rover.locked ? 'locked' : 'available';
lines.push(`${clean(rover.name, 60)}: ${status}`);
}
let content = lines.join('\n') || 'No rovers online.';
if (content.length > 2000) content = `${content.slice(0, 1950)}\n… More rovers online.`;
return { content, allowedMentions: { parse: [] } };
}
function schedule() {
if (stopped || running || timer || !pending || !client.isReady()
|| !discordConfig.channels?.liveStatus?.trim()) return;
// Use a fixed deadline rather than resetting a debounce on every sensor
// frame: a continuously changing rover must not postpone updates forever.
timer = setTimeout(() => {
timer = null;
flush();
}, Math.max(0, nextUpdateAt - Date.now()));
timer.unref?.();
}
function update(forceCleanup = false) {
if (stopped) return;
pending = true;
cleanupNeeded ||= forceCleanup;
schedule();
}
async function flush() {
if (stopped || running || !client.isReady()) return;
pending = false;
const target = discordConfig.channels?.liveStatus?.trim();
if (target !== channelId) {
channelId = target;
messageId = null;
lastContent = null;
}
if (!target) return;
running = true;
const cleanup = cleanupNeeded;
cleanupNeeded = false;
let usedDiscord = false;
const active = () => !stopped && client.isReady()
&& discordConfig.channels?.liveStatus?.trim() === target;
try {
const report = buildReport();
const replace = report.content !== lastContent;
// Sensor events can arrive many times per second. Compare the rendered
// report locally before consuming any Discord API capacity.
if (!replace && !cleanup) return;
usedDiscord = true;
const channel = await fetchChannel(target);
if (!active()) return;
if (!channel?.isTextBased() || !channel.guild || !channel.messages || !channel.send) {
throw new Error('Live status requires a guild text channel');
}
const permissions = channel.permissionsFor(client.user);
if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory,
PermissionFlagsBits.SendMessages, PermissionFlagsBits.ManageMessages])) {
throw new Error('Live status requires View Channel, Read Message History, Send Messages, and Manage Messages');
}
let found = false;
let before;
// Paginate the entire history, including pinned and old messages. Individual
// deletion also handles messages too old for Discord's bulk-delete endpoint.
while (active()) {
const messages = await channel.messages.fetch({ limit: 100, before, cache: false });
if (!active()) return;
for (const message of messages.values()) {
if (!active()) return;
if (!replace && message.id === messageId) {
found = true;
continue;
}
try {
await message.delete();
} catch (err) {
if (err.code !== 10008) throw err; // Already-deleted messages need no cleanup.
}
}
if (messages.size < 100) break;
before = messages.last().id;
}
if (!active()) return;
if (replace || !found) {
const message = await channel.send(report);
if (!active()) return;
messageId = message.id;
lastContent = report.content;
}
} catch (err) {
cleanupNeeded = true;
logger.warn('Discord live status update failed', { channelId: target, error: err.message });
} finally {
running = false;
// Cool down after completion, so slow deletions and rate-limited requests
// cannot overlap with another batch. Events received meanwhile stay pending.
if (usedDiscord) nextUpdateAt = Date.now() + COOLDOWN_MS;
schedule();
}
}
const onChange = () => update();
function onDelete(message) {
if (message.channelId === discordConfig.channels?.liveStatus?.trim()
&& message.id === messageId) update(true);
}
function onBulkDelete(messages) {
for (const message of messages.values()) onDelete(message);
}
return {
update,
start() {
if (stopped) return;
if (!listening) {
listening = true;
for (const event of ['rover', 'sensor', 'lock', 'private', 'help']) {
roverManager.managerEvents.on(event, onChange);
unsubscribe.push(() => roverManager.managerEvents.off(event, onChange));
}
turnEvents.on('activeDriver', onChange);
nicknameEvents.on('change', onChange);
client.on('messageDelete', onDelete);
client.on('messageDeleteBulk', onBulkDelete);
unsubscribe.push(
() => turnEvents.off('activeDriver', onChange),
() => nicknameEvents.off('change', onChange),
() => client.off('messageDelete', onDelete),
() => client.off('messageDeleteBulk', onBulkDelete),
);
}
clearInterval(sweepTimer);
sweepTimer = null;
if (discordConfig.channels?.liveStatus?.trim()) {
// Recover from missed gateway events and failed operations without polling
// for normal rover changes, which are handled by the subscriptions above.
sweepTimer = setInterval(() => update(true), 30000);
sweepTimer.unref?.();
}
update(true);
},
stop() {
stopped = true;
clearTimeout(timer);
clearInterval(sweepTimer);
unsubscribe.forEach((remove) => remove());
},
};
}
module.exports = { createLiveStatus };
@@ -7,6 +7,7 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
const PRESENCE_ROTATE_MS = 20000;
let presenceInterval = null;
let presenceShowObjective = false;
let stopped = false;
function truncatePresenceText(text, maxLength) {
if (!text) return '';
@@ -40,6 +41,7 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
}
function schedulePresenceRotation() {
if (stopped) return;
if (presenceInterval) {
clearInterval(presenceInterval);
presenceInterval = null;
@@ -59,6 +61,11 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
}
return {
stop() {
stopped = true;
clearInterval(presenceInterval);
presenceInterval = null;
},
schedulePresenceRotation,
};
}
@@ -52,7 +52,7 @@ async function setEnabled(nextValue, options = {}) {
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
if (failures.length) {
logger.warn('Some room controls failed to enter green mode', { failures });
logger.warn('Some room lights failed to enter green mode', { failures });
}
} else if (!next && homeAssistantService.enabled) {
// Disabling the visual mode simply releases the lock it created. Bulb
@@ -67,7 +67,7 @@ async function setEnabled(nextValue, options = {}) {
skipping the physical-room operations still allows the session theme,
CardFrame styling, alerts, commands, and timed reward to work normally.
The integration's generic lock state is also left untouched because there
are no server-managed room controls to lock.
are no server-managed room lights to lock.
*/
enabled = next;
@@ -0,0 +1,54 @@
// User and idle commands share one dispatch contract, while only the trusted
// idle entry point can bypass per-item user locks.
const { buildEntity, buildCommand } = require('./entityHelpers');
function assertAccess(actor = {}) {
if (!['user', 'admin', 'lockdown'].includes(actor.role)) throw new Error('Spectators cannot control activities');
if (actor.mode === 'lockdown' && actor.role !== 'lockdown') throw new Error('Server in lockdown');
if (actor.mode === 'admin' && !['admin', 'lockdown'].includes(actor.role)) throw new Error('Admin mode: admins only');
}
function createActions({ getConfig, ha, locks }) {
async function execute(id, value, actor, idle = false) {
if (!idle) assertAccess(actor);
const config = getConfig();
if (!config.enabled) throw new Error('Activity Controls are disabled');
const item = config.items.find((entry) => entry.id === id);
if (!item) throw new Error('Unknown activity item');
if (!idle && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
if (!ha.enabled || !ha.isConnected()) throw new Error('Home Assistant is offline');
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
if (!entity.available) throw new Error('This item is unavailable');
const command = buildCommand(entity, value);
// Let HA process independent commands normally; an outstanding service
// response must not block later user changes or configured idle cleanup.
await ha.callHomeAssistantService(entity.domain, command.service, command.data);
}
async function runIdleActions() {
const config = getConfig();
if (!config.enabled) return { action: 'homeAssistantActivitiesIdle', skipped: true };
const results = [];
// Catch per item so one offline integration cannot prevent the remaining
// configured cleanup actions from running during this idle window.
for (const item of config.items) {
if (!item.idleAction || item.idleAction === 'unchanged' || item.readOnly) continue;
try {
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
if (entity.type === 'readOnly') continue;
if ((item.idleAction === 'press') !== (entity.type === 'button')) throw new Error('Idle action does not match the control type');
// The admin form omits an empty optional string. Treat that as empty
// text so clearing a message works; other types still reject it through
// their normal value validation rather than silently receiving zero.
await execute(item.id, item.idleAction === 'press' ? 'press' : (item.idleValue ?? ''), null, true);
results.push({ id: item.id, ok: true });
} catch (error) {
results.push({ id: item.id, ok: false, error: error.message });
}
}
return { action: 'homeAssistantActivitiesIdle', results };
}
return { act: (id, value, actor) => execute(id, value, actor), runIdleActions };
}
module.exports = { createActions, assertAccess };
@@ -0,0 +1,148 @@
// Exercise contracts without starting the app or connecting to Home Assistant.
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildEntity, buildCommand } = require('./entityHelpers');
const { createActions } = require('./actions');
const { createLocks, resolveItem } = require('./locks');
const { createHaCommand } = require('../operatorCommandService/commands/ha');
const user = { role: 'user', mode: 'open' };
const raw = (state, attributes = {}) => ({ state, attributes });
function harness(items, snapshot) {
const calls = [];
const config = { enabled: true, items };
const locked = new Set();
const ha = {
enabled: true, isConnected: () => true,
getRawEntitySnapshot: (id) => snapshot[id],
callHomeAssistantService: async (...args) => { calls.push(args); },
};
return { config, ha, calls, locked, actions: createActions({ getConfig: () => config, ha, locks: { isLocked: (id) => locked.has(id) } }) };
}
test('sensor states, live constraints, and unknown button states are preserved', () => {
const sensor = buildEntity({ id: 'sensor.humidity' }, raw('46.3', { unit_of_measurement: '%', friendly_name: 'Humidity' }));
assert.equal(sensor.state, '46.3');
assert.equal(sensor.unit, '%');
assert.equal(sensor.type, 'readOnly');
assert.equal(sensor.name, 'Humidity');
assert.equal(buildEntity({ id: 'button.bell' }, raw('unknown')).available, true);
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
assert.equal(buildEntity({ id: 'button.bell' }, null).available, false);
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'readOnly');
});
test('domain dispatch handles native entities and helpers without room-light payloads', async () => {
const cases = [
['light.lamp', 'on', {}, 'turn_on', {}],
['switch.fan', 'off', {}, 'turn_off', {}],
['input_boolean.enabled', 'on', {}, 'turn_on', {}],
['number.speed', 0.3, { min: 0, max: 1, step: 0.1 }, 'set_value', { value: 0.3 }],
['input_number.speed', '2', { min: 0, max: 5, step: 1 }, 'set_value', { value: 2 }],
['text.message', 'hello', { min: 0, max: 20 }, 'set_value', { value: 'hello' }],
['input_text.message', '', { min: 0, max: 20 }, 'set_value', { value: '' }],
['select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
['input_select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
['button.bell', 'press', {}, 'press', {}],
['input_button.bell', 'press', {}, 'press', {}],
];
// Each domain is exercised through the allowlist and action service rather
// than merely asserting that an internal mapping table contains an entry.
for (const [id, value, attributes, service, data] of cases) {
const h = harness([{ id }], { [id]: raw('unknown', attributes) });
if (!id.includes('button')) h.ha.getRawEntitySnapshot = () => raw('off', attributes);
await h.actions.act(id, value, user);
assert.deepEqual(h.calls, [[id.split('.')[0], service, { entity_id: id, ...data }]]);
}
});
test('invalid values fail before an outbound request', () => {
const number = buildEntity({ id: 'number.speed' }, raw('0', { min: 0, max: 1, step: 0.1 }));
for (const value of ['', ' ', null, false, {}, Infinity, 'NaN', 2, 0.15]) assert.throws(() => buildCommand(number, value));
const text = buildEntity({ id: 'text.code' }, raw('ab', { min: 2, max: 4, pattern: '[a-z]+' }));
for (const value of ['', 'abcde', 123]) assert.throws(() => buildCommand(text, value));
// Pattern interpretation belongs to HA, even when metadata supplies one.
assert.equal(buildCommand(text, '12').data.value, '12');
assert.throws(() => buildCommand(buildEntity({ id: 'select.mode' }, raw('Quiet', { options: ['Quiet'] })), 'Other'));
assert.throws(() => buildCommand(buildEntity({ id: 'sensor.state' }, raw('ok')), 'on'), /read-only/);
});
test('permissions, locks, availability and allowlist are authoritative', async () => {
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('on') });
await assert.rejects(h.actions.act('switch.room_only', 'off', user), /Unknown/);
for (const actor of [{ role: 'spectator', mode: 'open' }, { role: 'user', mode: 'admin' }, { role: 'admin', mode: 'lockdown' }]) {
await assert.rejects(h.actions.act('switch.fan', 'off', actor));
}
h.locked.add('switch.fan');
await assert.rejects(h.actions.act('switch.fan', 'off', user), /locked/);
assert.equal(h.calls.length, 0);
await h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' });
await h.actions.act('switch.fan', 'off', { role: 'lockdown', mode: 'lockdown' });
h.ha.isConnected = () => false;
await assert.rejects(h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' }), /offline/);
h.config.enabled = false;
await assert.rejects(h.actions.act('switch.fan', 'off', user), /disabled/);
});
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
const h = harness([
{ id: 'switch.keep' },
{ id: 'number.bad', idleAction: 'set', idleValue: '200' },
{ id: 'switch.fan', idleAction: 'set', idleValue: 'off' },
// An empty optional text box is omitted by the schema-driven admin form.
{ id: 'input_text.message', idleAction: 'set' },
{ id: 'input_button.stop', idleAction: 'press' },
{ id: 'sensor.humidity', idleAction: 'set', idleValue: '0' },
{ id: 'switch.readonly', readOnly: true, idleAction: 'set', idleValue: 'off' },
], {
'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
});
h.locked.add('switch.fan');
const result = await h.actions.runIdleActions();
assert.equal(result.results[0].ok, false);
assert.equal(result.results.filter((entry) => entry.ok).length, 3);
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }]);
});
test('an outstanding service response does not block later commands', async () => {
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('off') });
const completions = [];
h.ha.callHomeAssistantService = () => new Promise((resolve) => { completions.push(resolve); });
const first = h.actions.act('switch.fan', 'on', user);
const second = h.actions.act('switch.fan', 'off', user);
// Both commands reach HA before either response arrives.
assert.equal(completions.length, 2);
completions.forEach((finish) => finish());
await Promise.all([first, second]);
});
test('locks are process-local and names resolve without guessing', () => {
const locks = createLocks();
locks.setLocked('number.fan', true);
assert.equal(locks.isLocked('number.fan'), true);
// A fresh service starts unlocked instead of restoring previous moderation.
assert.equal(createLocks().isLocked('number.fan'), false);
locks.setLocked('number.fan', false);
assert.equal(locks.isLocked('number.fan'), false);
const items = [{ id: 'number.fan', name: 'Fan speed' }, { id: 'number.other', name: 'Fan speed' }];
assert.equal(resolveItem(items.slice(0, 1), 'FAN SPEED').id, 'number.fan');
assert.equal(resolveItem(items, 'NUMBER.FAN').id, 'number.fan');
assert.throws(() => resolveItem(items, 'Fan speed'), /number.fan, number.other/);
// Keep the lookup error aligned with the panel name users see when choosing an item.
assert.throws(() => resolveItem(items, 'Fan'), /No activity control/);
});
test('command preserves multiword names and exposes lock status', async () => {
const changes = [];
const replies = [];
const handler = createHaCommand({ config: { commands: { prefix: 'rs' } }, homeAssistantActivitiesService: {
setLocked: (name, locked) => { changes.push([name, locked]); return { id: 'number.fan', name }; },
getState: () => ({ items: [{ id: 'number.fan', name: 'Fan speed', locked: true }] }),
} });
const message = { reply: (reply) => { replies.push(reply.content); } };
await handler(message, ['lock', 'Fan', 'speed']);
await handler(message, ['status']);
assert.deepEqual(changes, [['Fan speed', true]]);
assert.match(replies[1], /Fan speed \(number.fan\): locked/);
});
@@ -0,0 +1,27 @@
// Activity Controls have their own catalog so room-light bulk actions
// never acquire unrelated devices simply because they share a connection.
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
module.exports = {
key: 'homeAssistantActivities',
feature: true,
defaultValue: { enabled: false, items: [] },
schema: strictObject({
enabled: boolean({ description: 'Shows the Activity Controls card in Activities using the enabled Home Assistant connection.' }),
items: {
type: 'array',
title: 'Activity control items',
description: 'Ordered public entities. Control types and limits come from Home Assistant; unsupported domains are read-only.',
items: strictObject({
id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID to expose in Activity Controls.', pattern: '^[a-z0-9_]+\\.[a-z0-9_]+$', maxLength: 255, examples: ['input_number.fan_speed'] }),
name: string({ title: 'Display name', description: 'Optional override; otherwise uses the Home Assistant friendly name.', maxLength: 120, examples: ['Fan speed'] }),
icon: string({ description: 'Font Awesome name, as used by social links. Blank or invalid names use a fallback.', maxLength: 80, examples: ['FaFan'] }),
// Match social-link colors so admins can customize tiles through the existing config form.
color: string({ description: 'Optional six-digit hexadecimal tile color, like social links.', examples: ['#3B82F6'], pattern: '^#[0-9a-fA-F]{6}$' }),
readOnly: boolean({ title: 'Read only', default: false, description: 'Display the value without allowing user or idle commands.' }),
idleAction: string({ title: 'When idle', enum: ['unchanged', 'set', 'press'], default: 'unchanged', description: 'Leave unchanged, set the idle value, or press a button once. Runs independently of user locks.' }),
idleValue: string({ title: 'Idle value', description: 'For set: on/off, a number, exact selection, or text (empty text clears it). Checked against live entity limits when idle runs.', examples: ['0'], maxLength: 255 }),
}, { description: 'One independently controlled or read-only activity control item.', required: ['id'] }),
},
}, { title: 'Activity Controls', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }),
};
@@ -0,0 +1,69 @@
// Only these domains have a known write contract. All other entities retain
// their actual state as read-only text instead of being coerced to on/off.
const TYPES = {
light: 'toggle', switch: 'toggle', input_boolean: 'toggle',
number: 'number', input_number: 'number', text: 'text', input_text: 'text',
select: 'select', input_select: 'select', button: 'button', input_button: 'button',
};
function buildEntity(item, raw, locked = false) {
const attributes = raw?.attributes || {};
const domain = item.id.split('.')[0];
const type = item.readOnly ? 'readOnly' : TYPES[domain] || 'readOnly';
// A never-pressed button legitimately reports unknown. Its state is a last
// press timestamp, not availability or a boolean toggle state.
const available = Boolean(raw && raw.state !== 'unavailable' && (raw.state !== 'unknown' || type === 'button'));
const numeric = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
return {
id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id,
// Include presentation settings in session state so all clients share the configured color.
icon: item.icon || '', color: item.color || '', domain, type, locked, available,
state: raw?.state ?? 'unknown', unit: attributes.unit_of_measurement || '',
min: numeric(attributes.min), max: numeric(attributes.max), step: numeric(attributes.step),
options: Array.isArray(attributes.options) ? attributes.options.filter((option) => typeof option === 'string') : [],
password: attributes.mode === 'password',
};
}
function buildCommand(entity, value) {
const data = { entity_id: entity.id };
// Explicit state writes avoid racing a server-side toggle against another
// user's click. Each branch validates the current HA metadata, not the UI.
switch (entity.type) {
case 'toggle':
if (value !== 'on' && value !== 'off') throw new Error('Expected on or off');
return { service: value === 'on' ? 'turn_on' : 'turn_off', data };
case 'button':
if (value !== 'press') throw new Error('Expected press');
return { service: 'press', data };
case 'number': {
if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') throw new Error('Enter a number');
const number = Number(value);
if (!Number.isFinite(number)) throw new Error('Enter a finite number');
if (entity.min === null || entity.max === null) throw new Error('Number limits are unavailable');
if (number < entity.min || number > entity.max) throw new Error(`Value must be between ${entity.min} and ${entity.max}`);
// Use a tolerance for decimal steps because binary floating point cannot
// exactly represent values such as 0.1. The range is still checked above.
if (entity.step > 0) {
const steps = (number - entity.min) / entity.step;
if (Math.abs(steps - Math.round(steps)) > 1e-7) throw new Error(`Value must use steps of ${entity.step}`);
}
return { service: 'set_value', data: { ...data, value: number } };
}
case 'text': {
if (typeof value !== 'string') throw new Error('Expected text');
const length = Array.from(value).length;
if (length < (entity.min ?? 0) || length > (entity.max ?? 255)) throw new Error('Text is outside the allowed length');
// Keep type/length checks here; HA owns integration-specific text rules
// so its patterns are not reinterpreted by a different regex engine.
return { service: 'set_value', data: { ...data, value } };
}
case 'select':
if (!entity.options.includes(value)) throw new Error('Choose an available option');
return { service: 'select_option', data: { ...data, option: value } };
default:
throw new Error('This item is read-only');
}
}
module.exports = { buildEntity, buildCommand };
@@ -0,0 +1,79 @@
// Separate activity ownership over the shared HA transport: no room-light
// catalog, policy, or automation is consulted by this service.
const EventEmitter = require('events');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const ha = require('../homeAssistantService');
const io = require('../../globals/io');
const { buildEntity } = require('./entityHelpers');
const { createLocks, resolveItem } = require('./locks');
const { createActions } = require('./actions');
const { getRole } = require('../roleService');
const { getMode } = require('../modeManager');
const logger = require('../../globals/logger').child('homeAssistantActivitiesService');
const events = new EventEmitter();
let config = loadConfig().homeAssistantActivities;
const locks = createLocks();
// A replacement connection must deliver its own snapshot before accepting
// writes; cached metadata may belong to the previously configured HA server.
let snapshotReady = false;
const actions = createActions({ getConfig: () => config, ha: {
get enabled() { return ha.enabled; },
isConnected: () => snapshotReady && ha.isConnected(),
getRawEntitySnapshot: ha.getRawEntitySnapshot,
callHomeAssistantService: ha.callHomeAssistantService,
}, locks });
function getState() {
return {
enabled: config.enabled,
connected: ha.enabled && snapshotReady && ha.isConnected(),
items: config.enabled ? config.items.map((item) => buildEntity(item, ha.getRawEntitySnapshot(item.id), locks.isLocked(item.id))) : [],
};
}
let lastState;
function emitUpdate() {
const state = getState();
const serialized = JSON.stringify(state);
// HA snapshots include every entity in the installation. Only changes to
// this public allowlist should cause additional session broadcasts.
if (serialized === lastState) return;
lastState = serialized;
events.emit('update', state);
}
function setLocked(query, locked) {
const item = resolveItem(getState().items, query);
locks.setLocked(item.id, locked);
emitUpdate();
return item;
}
ha.homeAssistantEvents.on('snapshot', () => {
snapshotReady = true;
emitUpdate();
});
ha.homeAssistantEvents.on('status', () => {
snapshotReady = false;
emitUpdate();
});
registerConfigurationHandler('homeAssistantActivities', (next) => {
config = next;
emitUpdate();
});
// Resolve access on the server for every command; clients only supply item/value.
io.on('connection', (socket) => {
socket.on('homeAssistantActivities:act', async (payload) => {
try {
await actions.act(payload?.id, payload?.value, { role: getRole(socket), mode: getMode() });
} catch (error) {
// No acknowledgement state is sent to the browser: actual entity
// changes arrive through the shared snapshot stream. Keep failures in
// server logs without creating a second UI state protocol.
logger.warn('Activity command failed', { id: payload?.id, error: error.message });
}
});
});
module.exports = { ...actions, getState, setLocked, activityEvents: events };
@@ -0,0 +1,26 @@
// Locks last only for this server process. Keying by entity ID keeps a display
// name change from unlocking an item while avoiding any persistence machinery.
function createLocks() {
const lockedIds = new Set();
return {
isLocked: (id) => lockedIds.has(id),
setLocked(id, locked) {
if (locked) lockedIds.add(id);
else lockedIds.delete(id);
},
};
}
function resolveItem(items, query) {
const normalized = String(query || '').trim().toLowerCase();
// Exact IDs take precedence; names deliberately do not use fuzzy matching
// because a moderation command must never lock a merely similar device.
const idMatch = items.find((item) => item.id.toLowerCase() === normalized);
if (idMatch) return idMatch;
const matches = items.filter((item) => item.name.toLowerCase() === normalized);
if (matches.length > 1) throw new Error(`Name is ambiguous. Use an entity ID: ${matches.map((item) => item.id).join(', ')}`);
if (!matches.length) throw new Error('No activity control item matches that name or entity ID');
return matches[0];
}
module.exports = { createLocks, resolveItem };
@@ -52,14 +52,14 @@ module.exports = {
],
},
schema: strictObject({
enabled: boolean({ description: 'Immediately connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls.' }),
enabled: boolean({ description: 'Immediately connects to Home Assistant and enables configured room lights, physical-button triggers, Neato controls, and lift controls.' }),
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.', examples: ['REPLACE_WITH_LONG_LIVED_TOKEN'], writeOnly: true, maxLength: 20000 }),
[neato.key]: neato.schema,
[lift.key]: lift.schema,
entities: {
type: 'array',
title: 'Room entities',
title: 'Room Lights',
description: 'Home Assistant lights and switches exposed to the room-light controls and button-box actions.',
items: strictObject({
id: string({ title: 'Entity id', description: 'Exact Home Assistant entity ID, such as light.rover_room or switch.floor_lamp.', examples: ['light.lab_main'], minLength: 1, maxLength: 255 }),
@@ -55,7 +55,7 @@ function registerHomeAssistantHooks(deps) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' });
return cb({ error: 'Room lights are locked' });
}
try {
if (!entityId) throw new Error('entityId required');
@@ -71,7 +71,7 @@ function registerHomeAssistantHooks(deps) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' });
return cb({ error: 'Room lights are locked' });
}
try {
if (!entityId) throw new Error('entityId required');
@@ -87,7 +87,7 @@ function registerHomeAssistantHooks(deps) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' });
return cb({ error: 'Room lights are locked' });
}
try {
if (!entityId) throw new Error('entityId required');
@@ -106,7 +106,7 @@ function registerHomeAssistantHooks(deps) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' });
return cb({ error: 'Room lights are locked' });
}
try {
if (!entityId) throw new Error('entityId required');
+4 -1
View File
@@ -5,6 +5,7 @@ const logger = require('../../globals/logger').child('idleService');
const roverManager = require('../roverManager');
const { issueCommand } = require('../commandService');
const homeAssistantService = require('../homeAssistantService');
const homeAssistantActivities = require('../homeAssistantActivitiesService');
const neatoService = require('../neatoService');
const liftService = require('../liftService');
const ptzCameraService = require('../ptzCameraService');
@@ -18,7 +19,7 @@ async function turnOffRoomControls() {
const lightPolicy = homeAssistantService.getLightPolicyState?.() || null;
const lockState = lightPolicy?.lockState || null;
if (lockState === 'on') {
logger.info('Idle room-controls off skipped because room controls are locked on', {
logger.info('Idle room-controls off skipped because room lights are locked on', {
lockState,
source: 'idleService:turnOffRoomControls',
});
@@ -135,6 +136,8 @@ async function raiseLift() {
const idleActions = [
turnOffRoomControls,
// Generic activities use their own configured idle values and user locks.
homeAssistantActivities.runIdleActions,
// dockAllRovers,
disableAllRoverHeadlights,
disableAllRoverLasers,
@@ -370,11 +370,6 @@ function createSnapshotEngine(deps) {
.filter((entry) => {
const roverId = entry?.roverId ? String(entry.roverId) : null;
if (!roverId) return true;
/*
This is a chat transcript filter, not a physical-rover filter. PTZ
chat intentionally carries a rover-like id so transcript consumers can
render it consistently, even though roverManager cannot replay that id.
*/
return isPublicChatTargetId(roverId);
});
const chatRecent = allRecentMessages
@@ -430,6 +425,8 @@ function createSnapshotEngine(deps) {
type: 'chat',
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
text: entry.text || '',
operating_mode: entry.operatingMode || null,
operating_mode_name: entry.operatingModeDisplay?.name || null,
rover_id: roverId,
rover_ctx: { ...baseCtx, ...storedCtx },
};
@@ -0,0 +1,74 @@
const EventEmitter = require('events');
const operatingModeEvents = new EventEmitter();
const transitions = new WeakMap();
function getOperatingMode(socket) {
// Handshake intent reserves the mode before role-driven assignment runs.
// It grants no camera permissions; entry still checks access and readiness.
return socket?.data?.operatingMode
|| (socket?.handshake?.auth?.operatingMode === 'ptz' ? 'ptz' : 'rover');
}
async function transition(socket, operatingMode) {
// Resolve services at the action boundary to avoid a dependency cycle with
// assignment and camera policy, which both read operating mode.
const assignment = require('../assignmentService');
const roverManager = require('../roverManager');
const ptz = require('../ptzCameraService');
if (operatingMode === 'rover' && getOperatingMode(socket) === 'rover') {
return { operatingMode, state: ptz.getPublicState(socket) };
}
if (operatingMode === 'ptz') {
await ptz.prepareParticipation(socket);
if (socket.disconnected) throw new Error('Session disconnected');
ptz.checkParticipationAccess(socket);
const leave = roverManager.canLeaveCurrentRover(socket);
if (!leave.ok) throw new Error(leave.message);
// Block new rover ownership before release events notify other services.
socket.data.operatingMode = 'ptz';
assignment.releaseForOperatingMode(socket);
socket.data.ptzEntered = true;
} else {
ptz.releaseTurn(socket);
socket.data.ptzEntered = false;
socket.data.operatingMode = 'rover';
assignment.resumeAssignment(socket);
}
operatingModeEvents.emit('change', { socket, operatingMode });
return { operatingMode, state: ptz.getPublicState(socket) };
}
function enqueueTransition(socket, action) {
// Camera initialization can yield. Serialize switches and turn release per
// socket so a late entry cannot undo a subsequent exit or release request.
const previous = transitions.get(socket) || Promise.resolve();
const next = previous.catch(() => {}).then(() => {
if (socket.disconnected) throw new Error('Session disconnected');
return action();
});
transitions.set(socket, next);
return next;
}
function setOperatingMode(socket, operatingMode) {
if (!['rover', 'ptz'].includes(operatingMode)) {
return Promise.reject(new Error('Invalid operating mode'));
}
return enqueueTransition(socket, () => transition(socket, operatingMode));
}
function runPtzTurnAction(socket, action) {
return enqueueTransition(socket, () => {
const ptz = require('../ptzCameraService');
if (action === 'release') return ptz.releaseTurn(socket);
if (action !== 'request') throw new Error('Invalid PTZ turn action');
if (getOperatingMode(socket) !== 'ptz' || !socket.data.ptzEntered) {
throw new Error('Enter PTZ before requesting a turn');
}
return ptz.claimTurn(socket);
});
}
module.exports = { getOperatingMode, setOperatingMode, runPtzTurnAction, operatingModeEvents };
@@ -0,0 +1,26 @@
// The shared dispatcher supplies admin/lockdown authorization for both chat
// transports. Preserve the remaining tokens so names with spaces need no quotes.
const { getCommandConfig } = require('../config');
function createHaCommand({ homeAssistantActivitiesService: service, config }) {
return async function handleHaCommand(message, tokens = []) {
const reply = (content) => message.reply({ content, allowedMentions: { parse: [], repliedUser: false } });
if (!service) return reply('Activity Controls are unavailable.');
const action = String(tokens[0] || 'status').toLowerCase();
try {
if (action === 'status') {
const items = service.getState().items;
return reply(items.length ? items.map((item) => `${item.name} (${item.id}): ${item.locked ? 'locked' : 'unlocked'}`).join('\n') : 'No activity control items configured.');
}
if (!['lock', 'unlock'].includes(action) || tokens.length < 2) {
return reply(`Use ${getCommandConfig(config).prefix} ha status, or ha <lock|unlock> <name or entity ID>.`);
}
const item = service.setLocked(tokens.slice(1).join(' '), action === 'lock');
return reply(`${item.name} (${item.id}) ${action === 'lock' ? 'locked' : 'unlocked'}.`);
} catch (error) {
return reply(error.message);
}
};
}
module.exports = { createHaCommand };
@@ -9,6 +9,7 @@ const { createGoalCommand } = require('./commands/goal');
const { createVerifyCommand } = require('./commands/verify');
const { createDeterCommand } = require('./commands/deter');
const { createPermissionsCommand } = require('./commands/permissions');
const { createHaCommand } = require('./commands/ha');
const { createLightsCommand } = require('./commands/lights');
const { createGreenCommand } = require('./commands/green');
const { createKickCommand } = require('./commands/kick');
@@ -61,6 +62,8 @@ function createCommandHandlers(deps) {
const handlePermissionsCommand = createPermissionsCommand(deps);
const handleBridgeCommand = transportHandlers.bridge;
const handleTimeStatusCommand = transportHandlers.timeStatus;
// HA activities have their own namespace; lights remains room-only.
const handleHaCommand = createHaCommand(deps);
const handleLightsCommand = createLightsCommand(deps);
const handleGreenCommand = createGreenCommand(deps);
const handleKickCommand = createKickCommand(deps);
@@ -110,7 +113,7 @@ function createCommandHandlers(deps) {
// is included because its lock/unlock subcommands change room policy. Its
// ordinary on/off/color actions are also intentionally restricted to a
// lockdown admin while the entire server is in lockdown.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'permissions', 'lights', 'green', 'kick', 'lift', 'neato']);
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'permissions', 'lights', 'ha', 'green', 'kick', 'lift', 'neato']);
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
// Feature commands are public activities while access is open or managed
@@ -144,6 +147,8 @@ function createCommandHandlers(deps) {
case 'bridge':
if (!handleBridgeCommand) return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: false, isFeatureEnabled: deps.isFeatureEnabled }));
return handleBridgeCommand(request, tokens);
case 'ha':
return handleHaCommand(request, tokens);
case 'lights':
return handleLightsCommand(request, tokens);
case 'green':
@@ -75,11 +75,17 @@ const admin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLo
test('admin-only commands stay admin-only for a non-admin', async () => {
const run = createRouter();
for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs green on', 'rs kick alice', 'rs permissions list']) {
for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs green on', 'rs kick alice', 'rs permissions list', 'rs ha lock Fan speed']) {
assert.match(await run(command, nonAdmin), ADMIN_DENIAL, `${command} must stay admin-only`);
}
});
// Activity locks obey the same stronger restriction as other moderation actions.
test('activity lock commands require lockdown admin during lockdown', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
assert.match(await run('rs ha unlock Fan speed', admin), LOCKDOWN_DENIAL);
});
test('commands that police themselves still reach their handler as a non-admin', async () => {
const run = createRouter();
// These reply with their own role-specific message, so the dispatcher must not
@@ -4,7 +4,7 @@
const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'green', 'kick', 'verify', 'deter', 'permissions'] },
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
features: { title: 'Features', names: ['lights', 'ha', 'lift', 'neato'] },
discord: { title: 'Discord', names: ['bridge'] },
};
@@ -23,6 +23,8 @@ function buildCommandRegistry(prefix, timeCommand) {
// an optional enhancement, so the command must remain available when that
// integration is absent.
green: { category: 'admin', summary: 'Toggle green room and page mode.', usage: [`${prefix} green <on|off>`], access: 'Admin', permission: 'admin' },
// Locks are moderation only; entity actions remain in the Activities card.
ha: { category: 'features', summary: 'List activity control locks or lock/unlock an item by name or entity ID.', usage: [`${prefix} ha status`, `${prefix} ha <lock|unlock> <name or entity ID>`], access: 'Admin', permission: 'admin', requiredFeature: 'homeAssistantActivities', unavailableLabel: 'Activity Controls' },
lights: {
category: 'features',
summary: 'Control room lights or manage the admin light lock.',
@@ -396,11 +396,6 @@ async function runDecision(triggerReason) {
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
.filter((entry) => {
if (!entry?.roverId) return true;
/*
Chat context should preserve every public chat target, including the
PTZ virtual rover. Rover replay visibility alone would drop PTZ because
it is owned by ptzCameraService instead of roverManager.
*/
return isPublicChatTargetId(entry.roverId);
})
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
@@ -2,7 +2,6 @@
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
const crypto = require('crypto');
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const { spawn } = require('child_process');
@@ -78,6 +77,7 @@ function createPtzAudioPlayback(deps) {
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
let disposed = false;
let playbackProc = null;
let playbackSeq = 0;
@@ -244,12 +244,16 @@ function createPtzAudioPlayback(deps) {
}, 1200);
}
async function playFile(filePath, context = {}) {
if (!audioEnabled) throw new Error('PTZ audio disabled');
async function playFile(filePath, context = {}, authorize = () => {}) {
if (disposed || !audioEnabled) throw new Error('PTZ audio unavailable');
authorize();
const neolinkConfigPath = await ensureNeolinkConfig();
const stat = await fsp.stat(filePath);
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
// Rendering and config IO may outlive a turn or camera configuration.
if (disposed) throw new Error('PTZ audio configuration changed');
authorize();
stopActivePlayback('new-playback');
const seq = ++playbackSeq;
const args = [
@@ -302,13 +306,13 @@ function createPtzAudioPlayback(deps) {
return { pid: proc.pid || null, seq };
}
async function speakText(text, ttsOptions = {}, context = {}) {
async function speakText(text, ttsOptions = {}, context = {}, authorize = () => {}) {
const rendered = await ensureTtsFile(text, ttsOptions);
await playFile(rendered.filePath, {
...context,
engine: rendered.engine,
cached: rendered.cached,
});
}, authorize);
return rendered;
}
@@ -316,6 +320,10 @@ function createPtzAudioPlayback(deps) {
getState,
speakText,
stopActivePlayback,
dispose: () => {
disposed = true;
stopActivePlayback('configuration-change');
},
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
// Owns PTZ turns only. Camera IO, permissions and operating-mode entry are supplied
// by the service, so queue transitions cannot create physical rover ownership.
function createParticipation({ io, events, emitChange, getTurnDurationMs, canParticipate, stopMotion, revokeVideo }) {
const state = { operatorSocketId: null, queue: [], deadline: null, generation: 0 };
let timer = null;
function remove(socketId) {
state.queue = state.queue.filter((id) => id !== socketId);
}
function stopOperator(reason) {
clearTimeout(timer);
timer = null;
const previous = state.operatorSocketId;
state.operatorSocketId = null;
state.deadline = null;
state.generation += 1;
if (!previous) return;
revokeVideo(previous);
stopMotion(reason).catch(() => {});
events.emit('operator', { socketId: previous, action: 'release', reason });
}
function scheduleTurn() {
// Uncontested control has no deadline. Starting a new wait gets a full turn,
// while later queue changes must not reset an already running countdown.
if (!state.operatorSocketId || !state.queue.length) {
clearTimeout(timer);
timer = null;
state.deadline = null;
return;
}
if (timer) return;
const duration = getTurnDurationMs();
state.deadline = Date.now() + duration;
timer = setTimeout(() => {
timer = null;
state.queue = state.queue.filter(eligible);
if (state.queue.length) {
// Preserve PTZ's explicit request-to-rejoin rule after a handoff.
stopOperator('turn-expired');
advance('turn-expired');
} else {
scheduleTurn();
emitChange('turn-uncontested');
}
}, duration);
}
function eligible(socketId) {
const socket = io.sockets.sockets.get(socketId);
return socket && !socket.disconnected && canParticipate(socket);
}
function advance(reason) {
state.queue = state.queue.filter(eligible);
while (!state.operatorSocketId && state.queue.length) {
const next = state.queue.shift();
if (!eligible(next)) continue;
state.operatorSocketId = next;
state.generation += 1;
events.emit('operator', { socketId: next, action: 'active' });
}
scheduleTurn();
emitChange(reason);
}
function claim(socket) {
if (!eligible(socket.id)) throw new Error('PTZ participation required');
if (state.operatorSocketId === socket.id || state.queue.includes(socket.id)) return;
state.queue.push(socket.id);
advance('queue-join');
}
function release(socketId, reason = 'turn-release') {
remove(socketId);
if (state.operatorSocketId === socketId) stopOperator(reason);
advance(reason);
}
function reconcile() {
state.queue = state.queue.filter(eligible);
if (state.operatorSocketId && !eligible(state.operatorSocketId)) stopOperator('access-change');
advance('access-change');
}
function clear(reason) {
state.queue = [];
stopOperator(reason);
}
return {
state, claim, release, reconcile, clear,
revoke: (reason) => { stopOperator(reason); advance(reason); },
};
}
module.exports = { createParticipation };
@@ -0,0 +1,123 @@
// Socket transport delegates policy and lifecycle decisions to the PTZ service.
function registerSocketGateway(deps) {
const { io, isAdmin, participation, emitChange, normalizeSocketArgs,
runPtzTurnAction, acceptMotionIntent, getStatus, setSpotlight, setIr,
listPresets, gotoPreset, createPreset, removePreset, passesMode,
normalizeSnapshotIds, addSnapshotSubscription, sendSnapshotFrame,
getSnapshot, removeSnapshotSubscriptions } = deps;
io.on('connection', (socket) => {
socket.on('ptzCamera:revokeOperator', (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
if (!isAdmin(socket)) return cb({ error: 'Admin required' });
participation.revoke('admin-revoke');
cb({ ok: true });
});
socket.on('ptzCamera:requestTurn', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try { cb({ ok: true, state: await runPtzTurnAction(socket, 'request') }); }
catch (err) { cb({ error: err.message }); }
});
socket.on('ptzCamera:release', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, state: await runPtzTurnAction(socket, 'release') });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
/*
Acknowledge acceptance of the newest desired state immediately. The
serialized ONVIF pump deliberately runs independently of Socket.IO
request latency so browser heartbeats cannot accumulate while waiting
for a camera SOAP response.
*/
cb(acceptMotionIntent(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:status', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, status: await getStatus(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:spotlight', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, light: await setSpotlight(socket, payload) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:ir', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, ir: await setIr(socket, payload) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:presets:list', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, presets: await listPresets(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:preset:goto', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await gotoPreset(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:preset:create', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await createPreset(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:preset:remove', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await removePreset(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:snapshotSubscribe', (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
if (!passesMode(socket)) throw new Error('Not authorized for PTZ snapshots');
const ids = normalizeSnapshotIds(payload);
addSnapshotSubscription(socket, ids);
const lastSnapshotState = getSnapshot();
if (lastSnapshotState?.frame) sendSnapshotFrame(socket, lastSnapshotState.frame, lastSnapshotState.ts);
cb({ ok: true, subscribed: ids });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:snapshotUnsubscribe', (firstArg, secondArg) => {
const { payload } = normalizeSocketArgs(firstArg, secondArg);
removeSnapshotSubscriptions(socket.id, normalizeSnapshotIds(payload));
});
socket.on('disconnect', () => {
participation.release(socket.id, 'disconnect');
removeSnapshotSubscriptions(socket.id);
emitChange('disconnect');
});
});
}
module.exports = { registerSocketGateway };
@@ -0,0 +1,16 @@
const labels = {
waiting: 'Someone else has control',
handoff: 'It’s your turn!',
active: 'You control the camera',
idleWarning: 'Start driving or your turn will be skipped',
activeTimer: 'Your turn',
waitingTimer: 'Waiting',
untilTurn: 'until your turn',
timeRemaining: 'left',
showTimer: 'Show turn timer',
hideTimer: 'Hide turn timer',
showName: 'Show camera name',
hideName: 'Hide camera name',
};
module.exports = labels;
@@ -68,13 +68,13 @@ function getDefaultWebSources(assignment = {}, socket = null) {
replay`, follows the camera currently controlled by that socket just as it
follows an assigned rover below.
isOperator is deliberately stricter than PTZ access or queue membership:
turn.isActive is deliberately stricter than PTZ access or queue membership:
spectators and users waiting for a camera turn must not silently replay a
camera they are not currently operating. Keeping this rule here also makes
every web replay entry point share the same default instead of teaching the
chat-command adapter about PTZ-specific state.
*/
if (ptzCameraService.getPublicState(socket).isOperator) {
if (ptzCameraService.getPublicState(socket).turn.isActive) {
const source = ptzCameraService.getReplaySource();
if (!source) return [];
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
@@ -1,3 +1,4 @@
const { getOperatingMode } = require('../operatingModeService');
// rover Manager private access
// Purpose: Encapsulates private-rover visibility and control-authorization policy checks.
// Scope: Keeps runtime behavior unchanged while isolating policy decisions from orchestration wiring.
@@ -80,6 +81,7 @@ function createPrivateAccessPolicy(deps) {
allowClosedPrivateGrantInLockdown = false,
allowClosedPrivateCurrentDriver = false,
} = options;
if (getOperatingMode(socket) !== 'rover') return 'Leave PTZ before requesting rover control';
if (!record) return 'Unknown rover';
if (!allowUser && !isAdmin(socket)) return 'Only admins can request control';
if (record.locked && !isAdmin(socket)) return 'Rover locked';
+37 -27
View File
@@ -5,19 +5,23 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('sessionService');
const { getFeatureFlags, configurationEvents } = require('../../configuration');
const { getRole, isAdmin, roleEvents } = require('../roleService');
const { getOperatingMode, setOperatingMode, operatingModeEvents } = require('../operatingModeService');
const { getMode, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager');
const { managerEvents } = roverManager;
const assignmentService = require('../assignmentService');
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
const { buildRoverTurn } = require('../turnService/display');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const {
getPublicState: getPtzCameraState,
getChatTargetForSocket: getPtzChatTargetForSocket,
PTZ_CAMERA_ID,
getOperatingModeDisplay,
getParticipantSocketIds,
ptzCameraEvents,
} = require('../ptzCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
// Publish activities separately so room-control consumers cannot operate them.
const { getState: getActivityState, activityEvents } = require('../homeAssistantActivitiesService');
const { isEnabled: isGreenModeEnabled, greenModeEvents } = require('../greenModeService');
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService');
@@ -109,18 +113,13 @@ function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) {
function countControllableUsers(userEntries = []) {
const ids = new Set();
const ptzParticipants = new Set(getParticipantSocketIds());
userEntries.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const socketId = String(entry?.socketId || '').trim();
const roverId = String(entry?.roverId || '').trim();
/*
buildUserEntry already maps PTZ queued/operators to the PTZ pseudo-rover
id and normal drivers to their physical rover. Counting entries after that
normalization gives the browser the same conceptual "controllable users"
count it shows in the user/queue panels without duplicating PTZ UI logic.
*/
if (socketId && roverId) ids.add(socketId);
if (socketId && (roverId || ptzParticipants.has(socketId))) ids.add(socketId);
});
return ids.size;
}
@@ -144,19 +143,14 @@ function buildUserEntry(socket) {
&& roverManager.isDriver(assignment.roverId, socket)
? assignment.roverId
: null;
const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
return {
socketId: socket.id,
userId: socket?.data?.userId || null,
nickname: getNickname(socket) || null,
role,
/*
PTZ is not inserted into the physical rover roster, but for chat and user
presence it should read like the user moved to a rover-like target. Prefer
the PTZ chat target while the socket is queued or operating so presence,
queue lookup, and chat identity all agree.
*/
roverId: ptzChatTarget?.roverId || verifiedPrimaryRover || verifiedAssignmentRover || null,
operatingMode: getOperatingMode(socket),
operatingModeDisplay: getOperatingModeDisplay(socket.id),
roverId: verifiedPrimaryRover || verifiedAssignmentRover || null,
};
}
@@ -169,15 +163,7 @@ function buildSession(socket) {
const controllableUserCount = countControllableUsers(userEntries);
const users = userEntries.map((entry) => ({
...entry,
/*
PTZ is intentionally not a roverManager record, so the normal physical
rover visibility filter would erase the user's PTZ chat target. Preserve
it here because getPtzChatTargetForSocket already applied the PTZ access
and queue/operator rules before buildUserEntry returned it.
*/
roverId: entry.roverId === PTZ_CAMERA_ID
? entry.roverId
: filterVisibleRoverId(socket, entry.roverId),
roverId: filterVisibleRoverId(socket, entry.roverId),
}));
const roster = roverManager.getRosterForSocket(socket);
const assignment = assignmentService.describeAssignment(socket?.id || '');
@@ -199,6 +185,7 @@ function buildSession(socket) {
return {
socketId: socket?.id || null,
role: getRole(socket),
operatingMode: getOperatingMode(socket),
mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
@@ -208,7 +195,17 @@ function buildSession(socket) {
of navigation and layout without maintaining another feature registry.
*/
features,
roster,
roster: roster.map((rover) => ({
...rover,
turn: buildRoverTurn({
rover,
mode: getMode(),
socketId: socket?.id,
turnInfo: turnQueues[rover.id],
activeDriverId: activeDrivers[rover.id],
users,
}),
})),
odometers: roverManager.getOdometersForSocket(socket),
assignment: {
...assignment,
@@ -220,6 +217,7 @@ function buildSession(socket) {
roomCameras: getRoomCameras(),
ptzCamera: getPtzCameraState(socket),
homeAssistant: getHomeAssistantState(),
homeAssistantActivities: getActivityState(),
// Green mode is a server-wide visual feature. It stays separate from Home
// Assistant state because HA only supplies the generic light operations.
greenMode: isGreenModeEnabled(),
@@ -277,10 +275,19 @@ function syncAll() {
}
io.on('connection', (socket) => {
socket.on('session:setOperatingMode', async ({ operatingMode } = {}, cb = () => {}) => {
try {
cb({ ok: true, ...await setOperatingMode(socket, operatingMode) });
} catch (err) {
cb({ error: err.message });
}
});
logger.info('New socket connected', socket.id);
syncSocket(socket);
});
operatingModeEvents.on('change', () => syncAll());
roleEvents.on('change', ({ socket }) => {
if (!socket) return;
logger.info('Role changed; syncing session', socket.id);
@@ -411,6 +418,9 @@ ptzCameraEvents.on('change', () => {
syncAll();
});
// The activity service filters unrelated HA snapshots before requesting a sync.
activityEvents.on('update', () => syncAll());
homeAssistantEvents.on('update', () => {
logger.info('Home Assistant state change; syncing all clients');
syncAll();
@@ -0,0 +1,46 @@
const { describeQueue } = require('./queueDisplay');
const { TURN_DURATION_MS, IDLE_TIMEOUT_MS } = require('./constants');
const labels = {
waiting: 'Someone else is driving',
handoff: 'It’s your turn!',
active: 'You’re driving',
idleWarning: 'Start driving or your turn will be skipped',
activeTimer: 'Your turn',
waitingTimer: 'Waiting',
untilTurn: 'until your turn',
timeRemaining: 'left',
showTimer: 'Show turn timer',
hideTimer: 'Hide turn timer',
showName: 'Show rover name',
hideName: 'Hide rover name',
};
// Build this from visibility-filtered session data so private rover state stays private.
function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId, users }) {
const queue = turnInfo?.queue || [];
// Direct ownership can arrive before queue details on load/reconnect.
const currentDriverId = activeDriverId || turnInfo?.current || null;
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
const userIndex = socketId ? queue.indexOf(socketId) : -1;
const enabled = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
const turnsAhead = enabled ? (userIndex - currentIndex + queue.length) % queue.length : null;
return {
target: { id: rover.id, name: rover.name, color: rover.color, fallback: rover.id },
...describeQueue(queue, currentDriverId),
userLabels: Object.fromEntries(queue.map((id) => [id,
users.find((user) => user.socketId === id)?.nickname || id,
])),
enabled,
isActive: turnsAhead === 0,
turnsAhead,
queueLength: queue.length,
deadline: turnInfo?.deadline || null,
durationMs: TURN_DURATION_MS,
idleDeadline: turnInfo?.idleDeadline || null,
idleGraceMs: IDLE_TIMEOUT_MS,
labels,
};
}
module.exports = { buildRoverTurn };
+2 -1
View File
@@ -1,6 +1,7 @@
// turn Service
// Purpose: Defines the turn Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { describeQueue } = require('./queueDisplay');
const io = require('../../globals/io');
const { sendAlert } = require('../alertService');
const { MODES, getMode, modeEvents } = require('../modeManager');
@@ -431,7 +432,7 @@ function getTurnQueues() {
driverQueues.forEach((queue, roverId) => {
payload[roverId] = {
mode,
queue: Array.from(queue.queue),
...describeQueue(Array.from(queue.queue), queue.current),
current: queue.current,
deadline: turnDeadlines.get(roverId) || null,
idleDeadline: idleDeadlines.get(roverId) || null,
@@ -0,0 +1,10 @@
// Shared display ordering only; each service still owns its queue policy.
function describeQueue(queue, currentId) {
const currentIndex = currentId ? queue.indexOf(currentId) : -1;
const nextId = queue.length > 1
? queue[(currentIndex + 1) % queue.length]
: null;
return { queue, currentId, nextId };
}
module.exports = { describeQueue };
@@ -157,11 +157,11 @@ function identifySocket(socket, payload = {}) {
}
/*
Spectator-style pages carry identity too, but only the driver surface
participates in duplicate-driver enforcement. This flag remains on the
Spectator-style pages carry identity too, but only the driver and PTZ surfaces
participate in duplicate-driver enforcement. This flag remains on the
socket because it is connection-specific, not person-specific.
*/
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
socket.data.identitySurface = ['driver', 'ptz'].includes(payload.identitySurface) ? payload.identitySurface : 'passive';
const result = identifyCanonicalSocket(socket, {
...payload,
@@ -207,7 +207,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
if (
!currentSocket?.id ||
!currentUserId ||
currentSocket.data?.identitySurface !== 'driver' ||
!['driver', 'ptz'].includes(currentSocket.data?.identitySurface) ||
!enforceForCurrentSocket
) {
return;
@@ -215,7 +215,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
if (candidate?.data?.identitySurface !== 'driver') return false;
if (!['driver', 'ptz'].includes(candidate?.data?.identitySurface)) return false;
return getUserIdForSocket(candidate) === currentUserId;
});
+1121 -1256
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
"dev": "VITE_ROVERD_URL=http://192.168.0.86:8080 vite",
"build": "vite build",
"lint": "eslint .",
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
@@ -15,12 +15,15 @@
"@rjsf/core": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"linkify-react": "^4.3.3",
"linkifyjs": "^4.3.3",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"react-icons": "^5.7.0",
"react-joystick-component": "^6.2.1",
"react-player": "^3.4.0",
"react-router-dom": "^7.9.6",
"three": "^0.184.0",
"uplot": "^1.6.32",
@@ -28,18 +31,17 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"autoprefixer": "^10.4.22",
"baseline-browser-mapping": "^2.11.25",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"socket.io-client": "4.8.3",
"tailwindcss": "^3.4.14",
"tailwindcss": "^4.3.3",
"vite": "^7.2.2",
"vitest": "^5.0.0"
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -137,7 +137,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
return (
<div ref={editorRef} className="configuration-editor">
<CardFrame title="Configuration" meta={`revision ${revision}`} clipOverflow={false} bodyClassName="p-0.5">
<div ref={toolbarRef} className="configuration-toolbar sticky top-0 z-20 mb-0.5 space-y-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur">
<div ref={toolbarRef} className="configuration-toolbar sticky top-0 z-20 mb-0.5 space-y-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur-sm">
<p className="text-xs text-slate-400">Saving applies the complete revision immediately and reloads each affected service.</p>
{/* All document actions stay together at the start of the toolbar. The
editor may use a wide canvas, but width is never used to separate a
@@ -21,7 +21,7 @@ export default function PasswordConfirmationDialog({ open, busy, error, onCancel
}
return (
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/75 p-2">
<div className="fixed inset-0 z-1000 flex items-center justify-center bg-black/75 p-2">
<form className="w-full max-w-sm border border-neutral-500/70 bg-neutral-900 p-1 text-slate-100 shadow-2xl" onSubmit={submit}>
<h2 className="text-base font-semibold">Confirm password</h2>
<p className="mt-0.5 text-xs text-slate-400">This sensitive action requires recent confirmation of your administrator password.</p>
+4 -1
View File
@@ -3,6 +3,9 @@
* Scope: Keeps route-specific hierarchy and RJSF integration out of the shared global stylesheet.
*/
/* Reuse the shared theme without emitting a second copy of Tailwind. */
@reference '../index.css';
.configuration-tree {
/* The large gap is intentionally limited to root siblings. It makes each
top-level configuration section read as a separate YAML block while the
@@ -91,7 +94,7 @@
.configuration-value input:not([type='checkbox']),
.configuration-value select,
.configuration-value textarea {
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-sky-500;
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-hidden focus:ring-1 focus:ring-sky-500;
}
.configuration-value input[type='checkbox'] {
@@ -504,7 +504,7 @@ export default function AdminPanelContent() {
value={reasonDraft}
onChange={(event) => setReasonDraft(event.target.value)}
placeholder="Set an admin mode reason"
className="field-input text-sm min-h-[3.5rem]"
className="field-input text-sm min-h-14"
/>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleReasonSave} className="button-dark">
@@ -558,7 +558,7 @@ export default function AdminPanelContent() {
{updateStates[rover.id] ? 'Updating...' : 'Update'}
</button>
{rover?.private?.enabled ? (
<div className="w-full rounded border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
<div className="w-full rounded-sm border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
<div className="text-slate-300">Private safety</div>
<label className="flex items-center gap-0.5">
<input
@@ -74,28 +74,28 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
{statPills.map((pill) => (
<span
key={pill.label}
className="rounded border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
className="rounded-sm border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
>
{pill.label}: {pill.value}
</span>
))}
</div>
{errors.message ? (
<div className="surface text-xs text-red-300 break-words">Error: {errors.message}</div>
<div className="surface text-xs text-red-300 wrap-break-word">Error: {errors.message}</div>
) : null}
{errors.details ? (
<details className="surface text-xs text-red-200">
<summary className="cursor-pointer select-none text-red-300">Failure details</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-red-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-red-200">
{JSON.stringify(errors.details, null, 2)}
</pre>
</details>
) : null}
{output.generated ? (
<div className="surface text-xs text-slate-200 break-words">Generated: {output.generated}</div>
<div className="surface text-xs text-slate-200 wrap-break-word">Generated: {output.generated}</div>
) : null}
{output.posted ? (
<div className="surface text-xs text-emerald-200 break-words">Posted: {output.posted}</div>
<div className="surface text-xs text-emerald-200 wrap-break-word">Posted: {output.posted}</div>
) : null}
<div className="grid gap-0.5 md:grid-cols-2">
<div className="space-y-0.5">
@@ -120,7 +120,7 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
</div>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Full Monitor Payload</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(state, null, 2)}
</pre>
</details>
@@ -154,7 +154,7 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
{selectedRun ? (
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Run #{selectedRun.runId} details</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(selectedRun, null, 2)}
</pre>
</details>
@@ -58,49 +58,49 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Latest Context</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{input.stateUpdate || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Exact Model Input (messages[])</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{renderModelMessages()}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Exact System Prompt</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{input.systemPrompt || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Exact Transcript Rows</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(input.transcript || [], null, 2)}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Tool Availability</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify({ available: input.availableTools, blocked: input.blockedTools }, null, 2)}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Exact Model Output</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{output.raw || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Live Tool Calls</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(output.liveToolCalls || [], null, 2)}
</pre>
</details>
@@ -115,7 +115,7 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Recent Runs</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(state.history || [], null, 2)}
</pre>
</details>
@@ -124,7 +124,7 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
{showPayload ? 'Hide Full Payload' : 'Show Full Payload'}
</button>
{showPayload ? (
<pre className="surface whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
<pre className="surface whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
) : null}
</div>
);

Some files were not shown because too many files have changed in this diff Show More