Compare commits

..
Author SHA1 Message Date
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
legop3 785cc85476 fix interinstance popup thingy
Container image / image (push) Failing after 55s
2026-09-15 21:18:09 -04:00
legop3 b47f1ba2ae less stupid description 2026-09-15 21:00:15 -04:00
legop3 8fb9278109 kick everyone and set update 2026-09-15 20:19:25 -04:00
59 changed files with 788 additions and 110 deletions
+1
View File
@@ -699,6 +699,7 @@ Implemented on 2026-09-15:
- Added fixed status, update-check, restart, and update operations. Update checks pull the configured moving image and compare Docker image IDs. Updates retain the previous image ID, recreate the application with its existing Compose host contract, wait for the image health check, and restore the previous image when replacement health fails.
- Persisted the current operation and result in the private lifecycle Unix-socket volume. This survives ordinary application replacement and browser reconnection without mounting the root lifecycle controller into the application's `/data` volume, leaving all application runtime paths owned by the non-root server.
- Connected the existing lockdown-administrator password confirmation and audit history to the lifecycle operations. The Administration overview polls persisted progress, reports update and rollback results, and keeps the legacy process-level restart only when no controller socket exists.
- Accepted self-updates, administrator restarts, and backup-restore restarts share a helper that sets the persistent admin reason to "server is restarting" and removes every current rover driver with the same notice. This does not change server mode or automatically clear the reason after startup.
- Defined the deployment image once through a Compose YAML anchor. Both services and the controller target reuse that exact value, so production stays on `ghcr.io/legop3/multiroombarover:latest` and development requires changing only the single visible selector line to a branch tag.
## 15. GHCR publishing automation
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
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DyvmFiy4.js";import{l as q,m as D,n as I}from"./api-BVIrbPAA.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-BiB6Mx6k.js.map
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
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
function o(t,i,a={}){return new Promise((n,s)=>{t.emit(i,a,(e={})=>{if(e?.error){const r=new Error(e.error);r.code=e.code||null,r.validationErrors=e.validationErrors||[],r.currentRevision=e.currentRevision||null,s(r);return}n(e)})})}const c=t=>o(t,"adminConfig:get"),u=(t,i)=>o(t,"adminConfig:confirmPassword",{password:i}),d=(t,i)=>o(t,"adminConfig:updateConfiguration",i),p=(t,i)=>o(t,"adminConfig:importConfigurationFile",i),m=(t,i)=>o(t,"adminConfig:restoreRevision",i),l=(t,i)=>o(t,"adminConfig:createAdministrator",i),f=(t,i)=>o(t,"adminConfig:updateAdministrator",i),g=(t,i)=>o(t,"adminConfig:deleteAdministrator",{id:i}),A=t=>o(t,"server:restartApplication"),C=t=>o(t,"server:lifecycleStatus"),R=t=>o(t,"server:checkForUpdate"),k=t=>o(t,"server:updateApplication"),v=t=>o(t,"backupRestore:status"),F=t=>o(t,"backupRestore:createBackup"),S=t=>o(t,"backupRestore:createRestoreUpload"),b=(t,i)=>o(t,"backupRestore:confirmRestore",{restoreId:i}),h=t=>o(t,"setup:status"),w=(t,i)=>o(t,"setup:createAdministrator",i),U=(t,i)=>o(t,"setup:importConfigurationFile",i);export{A as a,R as b,l as c,g as d,k as e,v as f,C as g,F as h,S as i,b as j,d as k,p as l,c as m,u as n,h as o,w as p,U as q,m as r,f as u};
//# sourceMappingURL=api-B8GGCEeo.js.map
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
function o(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const n=new Error(r.error);n.code=r.code||null,n.validationErrors=r.validationErrors||[],n.currentRevision=r.currentRevision||null,s(n);return}e(r)})})}const c=t=>o(t,"adminConfig:get"),u=(t,i)=>o(t,"adminConfig:confirmPassword",{password:i}),d=(t,i)=>o(t,"adminConfig:updateConfiguration",i),m=(t,i)=>o(t,"adminConfig:importConfigurationFile",i),f=(t,i)=>o(t,"adminConfig:restoreRevision",i),p=(t,i)=>o(t,"adminConfig:createAdministrator",i),l=(t,i)=>o(t,"adminConfig:updateAdministrator",i),g=(t,i)=>o(t,"adminConfig:deleteAdministrator",{id:i}),C=t=>o(t,"server:restartApplication"),A=t=>o(t,"backupRestore:status"),R=t=>o(t,"backupRestore:createBackup"),k=t=>o(t,"backupRestore:createRestoreUpload"),v=(t,i)=>o(t,"backupRestore:confirmRestore",{restoreId:i}),F=t=>o(t,"setup:status"),b=(t,i)=>o(t,"setup:createAdministrator",i),w=(t,i)=>o(t,"setup:importConfigurationFile",i);export{C as a,R as b,p as c,g as d,k as e,v as f,A as g,d as h,m as i,c as j,u as k,F as l,b as m,w as n,f as r,l as u};
//# sourceMappingURL=api-BVIrbPAA.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"api-BVIrbPAA.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\nexport const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');\nexport const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');\nexport const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');\nexport const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');\nexport const confirmFullRestore = (socket, restoreId) => emitAdminRequest(socket, 'backupRestore:confirmRestore', { restoreId });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","importAdminConfigurationFile","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","restartApplication","getBackupRestoreStatus","createFullBackup","createRestoreUpload","confirmFullRestore","restoreId","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,sCAAuCE,CAAO,EAC3HU,EAA+B,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9Ga,EAAsB,CAACf,EAAQgB,IAAOjB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAgB,CAAE,CAAE,EACxGC,EAAsBjB,GAAWD,EAAiBC,EAAQ,2BAA2B,EACrFkB,EAA0BlB,GAAWD,EAAiBC,EAAQ,sBAAsB,EACpFmB,EAAoBnB,GAAWD,EAAiBC,EAAQ,4BAA4B,EACpFoB,EAAuBpB,GAAWD,EAAiBC,EAAQ,mCAAmC,EAC9FqB,EAAqB,CAACrB,EAAQsB,IAAcvB,EAAiBC,EAAQ,+BAAgC,CAAE,UAAAsB,CAAS,CAAE,EAElHC,EAAkBvB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEwB,EAA2B,CAACxB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GuB,EAA0B,CAACzB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
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 -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-DyvmFiy4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D0j33plX.css">
<script type="module" crossorigin src="/assets/index-n0JxE1Mv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xCRVLGjq.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()) {
@@ -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,
@@ -27,6 +27,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 liftService = require('../liftService');
const neatoService = require('../neatoService');
const {
@@ -262,6 +264,7 @@ const commandDependencies = {
// 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,
@@ -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,
@@ -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.',
@@ -6,6 +6,9 @@ const logger = require('../../globals/logger').child('serverControlService');
const { getConfigurationDatabase } = require('../../configuration');
const { requireLockdownAdministrator, requireRecentPassword } = require('../adminConfigurationService');
const lifecycleClient = require('./lifecycleClient');
const { setAdminReason } = require('../adminReasonService');
const roverManager = require('../roverManager');
const assignmentService = require('../assignmentService');
const database = getConfigurationDatabase();
let restartPending = false;
@@ -14,6 +17,25 @@ function actorFor(socket) {
return socket?.data?.user?.username || socket.id;
}
function notifyDriversOfRestart(actor) {
// All requested restart paths share this persistent explanation. Updating
// the reason does not change server mode or arrange to clear it at startup.
const message = 'Server is restarting...';
setAdminReason(message, { by: actor });
for (const [roverId, rover] of roverManager.rovers) {
// Releasing control mutates the set, so snapshot the current drivers and
// send each the existing removal notice before their assignment changes.
for (const socketId of [...rover.drivers]) {
assignmentService.forceReleaseWithNotice(roverId, socketId, {
title: message,
message,
reasonCode: 'application-restart',
actor,
});
}
}
}
function scheduleApplicationRestart() {
if (restartPending) throw new Error('Application restart already pending.');
restartPending = true;
@@ -38,6 +60,7 @@ function scheduleApplicationRestart() {
function requestApplicationRestart({ actor, reason = 'administrator-requested' }) {
if (restartPending) throw new Error('Application restart already pending.');
database.recordAuditEvent(actor, 'application.restart-requested', { reason });
notifyDriversOfRestart(actor);
scheduleApplicationRestart();
logger.warn('Application restart requested', { actor, reason });
// Restore and ordinary admin restarts share this one browser contract, so
@@ -70,6 +93,8 @@ io.on('connection', (socket) => {
.then(() => requireRecentPassword(socket))
.then(() => lifecycleClient.updateApplication())
.then((lifecycle) => {
// Wait for controller acceptance so rejected updates do not kick users.
notifyDriversOfRestart(actorFor(socket));
database.recordAuditEvent(actorFor(socket), 'application.update-requested', {});
io.emit('server:restarting', { reason: 'application-update' });
cb({ success: true, lifecycle });
@@ -85,6 +110,9 @@ io.on('connection', (socket) => {
try {
const lifecycle = await lifecycleClient.restartApplication();
restartPending = true;
// Container restarts bypass the process-signal path, but need the same
// persistent reason and removal notices once the controller accepts.
notifyDriversOfRestart(actor);
database.recordAuditEvent(actor, 'application.restart-requested', { reason: 'administrator-requested' });
logger.warn('Application container restart requested', { actor });
io.emit('server:restarting', { reason: 'administrator-requested' });
@@ -18,6 +18,8 @@ const {
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');
@@ -220,6 +222,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(),
@@ -411,6 +414,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();
@@ -68,8 +68,8 @@ export const ACTIONS = [
{ id: 'videoFilterCycle', label: 'Cycle video filter', kind: 'button', section: 'Camera' },
{ id: 'songNoteUp', label: 'Play higher note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
{ id: 'songNoteDown', label: 'Play lower note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
{ id: 'homeAssistantOn', label: 'Turn next room control on', kind: 'button', section: 'Room controls' },
{ id: 'homeAssistantOff', label: 'Turn next room control off', kind: 'button', section: 'Room controls' },
{ id: 'homeAssistantOn', label: 'Turn next room light on', kind: 'button', section: 'Room lights' },
{ id: 'homeAssistantOff', label: 'Turn next room light off', kind: 'button', section: 'Room lights' },
/* Digital aux actions provide exact parity with the keyboard help surface. They coexist with
analog brush controls so each operator can choose proportional triggers or discrete buttons. */
{ id: 'auxMainForward', label: 'Main brush forward', kind: 'button', section: 'Aux buttons' },
@@ -0,0 +1,8 @@
export default function ButtonControl({ entity, disabled, onChange }) {
// HA button states are timestamps. A press is an action, never a toggle.
return <>
<button type="button" aria-label={`Press ${entity.name}`} disabled={disabled}
onClick={() => onChange('press')} className="button-dark w-full px-1 py-0.5 text-xs disabled:opacity-50">Press</button>
</>;
}
@@ -0,0 +1,27 @@
import useDraftControl from '../useDraftControl';
export default function NumberControl({ entity, disabled, onChange }) {
const limitsKnown = entity.min !== null && entity.max !== null;
const control = useDraftControl(onChange, disabled || !limitsKnown);
const value = control.draft ?? (entity.available ? entity.state : '');
const blocked = disabled || !limitsKnown;
// Sliders commit on release (including keyboard adjustment), not for every
// intermediate position. Number typing uses the same local draft and debounce.
return <>
<div className="flex min-w-0 items-center gap-0.5">
{limitsKnown ? <input type="range" aria-label={`${entity.name} slider`} min={entity.min} max={entity.max} step={entity.step || 'any'}
value={value || entity.min} disabled={blocked} className="min-w-0 flex-1 accent-emerald-500 disabled:opacity-50"
onChange={(event) => control.edit(event.target.value, false)}
onPointerUp={(event) => control.commit(event.currentTarget.value)}
onKeyUp={(event) => {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) control.commit(event.currentTarget.value);
}} /> : null}
<input type="number" aria-label={entity.name} min={entity.min ?? undefined} max={entity.max ?? undefined} step={entity.step || 'any'}
value={value} disabled={blocked} onChange={(event) => control.edit(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }}
className="w-16 min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
{entity.unit ? <span className="text-[0.65rem] text-slate-400">{entity.unit}</span> : null}
</div>
{!limitsKnown ? <span className="text-xs text-amber-200">Waiting for number limits</span> : null}
</>;
}
@@ -0,0 +1,6 @@
// Preserve raw sensor text and units; a sensor's non-on value is never "off".
export default function ReadOnlyControl({ entity }) {
return <span className="break-words text-xs text-slate-200">
{entity.available ? (entity.password ? '••••••' : `${entity.state}${entity.unit ? ` ${entity.unit}` : ''}`) : 'Unavailable'}
</span>;
}
@@ -0,0 +1,13 @@
export default function SelectControl({ entity, disabled, onChange }) {
// Keep the actual reported value visible even if HA changes its option list;
// only current options are selectable or accepted by the server.
return <>
<select aria-label={entity.name} value={entity.state} disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50">
{!entity.options.includes(entity.state) ? <option value={entity.state} disabled>{entity.state}</option> : null}
{entity.options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</>;
}
@@ -0,0 +1,19 @@
import { useRef } from 'react';
import useDraftControl from '../useDraftControl';
export default function TextControl({ entity, disabled, onChange }) {
const control = useDraftControl(onChange, disabled);
const composing = useRef(false);
// IME composition may pause mid-word, so it must finish before automatic
// sending starts. Enter remains an immediate action for ordinary typing.
return <>
<input type={entity.password ? 'password' : 'text'} aria-label={entity.name}
value={control.draft ?? (entity.available ? entity.state : '')} disabled={disabled}
minLength={entity.min ?? undefined} maxLength={entity.max ?? 255}
onChange={(event) => control.edit(event.target.value, !composing.current)}
onCompositionStart={() => { composing.current = true; control.cancel(); }}
onCompositionEnd={(event) => { composing.current = false; control.edit(event.currentTarget.value); }}
onKeyDown={(event) => { if (event.key === 'Enter' && !composing.current) control.commit(event.currentTarget.value); }}
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
</>;
}
@@ -0,0 +1,13 @@
export default function ToggleControl({ entity, disabled, onChange }) {
const on = entity.state === 'on';
// The label reflects reported state, rather than claiming success before HA
// publishes it. The entire compact button remains an accessible click target.
return <>
<button type="button" aria-label={`Toggle ${entity.name}`} aria-pressed={on}
disabled={disabled} onClick={() => onChange(on ? 'off' : 'on')}
className={`w-full rounded border px-1 py-0.5 text-xs font-semibold disabled:opacity-50 ${on ? 'border-emerald-700/70 bg-emerald-900 text-white' : 'border-neutral-700 bg-neutral-950 text-slate-300'}`}>
{on ? 'On' : 'Off'}
</button>
</>;
}
@@ -0,0 +1,62 @@
import { useCallback } from 'react';
import * as FaIcons from 'react-icons/fa';
import { FaCube, FaLock } from 'react-icons/fa';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext';
import CardFrame from '../CardFrame';
// Each control keeps its own JSX file; this panel only selects its renderer.
import ReadOnlyControl from './controls/ReadOnlyControl';
import ToggleControl from './controls/ToggleControl';
import NumberControl from './controls/NumberControl';
import TextControl from './controls/TextControl';
import SelectControl from './controls/SelectControl';
import ButtonControl from './controls/ButtonControl';
const controls = { readOnly: ReadOnlyControl, toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl, button: ButtonControl };
function ActivityTile({ entity, connected, allowed, admin }) {
const { homeAssistantActivityAct } = useSessionActions();
const onChange = useCallback((value) => homeAssistantActivityAct(entity.id, value), [homeAssistantActivityAct, entity.id]);
// Resolve precisely the same Font Awesome names accepted by social links.
// Unknown names remain usable with a neutral fallback rather than an error.
const candidate = FaIcons[entity.icon?.trim()];
const Icon = typeof candidate === 'function' ? candidate : FaCube;
// Translucent fills preserve text contrast; an amber border still identifies
// locked tiles regardless of the admin's chosen color.
const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6';
const Control = controls[entity.type] || ReadOnlyControl;
const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin);
return <div className="flex min-w-0 flex-col gap-0.5 rounded border px-0.5 py-0.5"
style={{ borderColor: entity.locked ? '#b45309' : `${color}aa`, backgroundColor: `${color}33` }}>
<div className="-mx-0.5 -mt-0.5 flex min-h-5 min-w-0 items-center gap-0.5 rounded-t px-0.5 py-0.5 text-white" style={{ backgroundColor: `${color}66` }}>
<Icon className="shrink-0 text-xs" aria-hidden="true" />
<span title={entity.name} className="min-w-0 flex-1 truncate text-[0.78rem] font-semibold leading-none">{entity.name}</span>
{entity.locked ? <span title={admin ? 'Locked for users; admins can still control' : 'Locked'} className="flex items-center gap-0.5 text-[0.65rem] text-amber-200"><FaLock aria-hidden="true" />Locked</span> : null}
</div>
{!entity.available && entity.type !== 'readOnly' ? <span className="text-xs text-amber-200">Unavailable</span> : null}
<Control entity={entity} disabled={disabled} onChange={onChange} />
</div>;
}
export default function HomeAssistantActivitiesPanel() {
const state = useSessionSelector((session) => session.session?.homeAssistantActivities);
const socketConnected = useSessionSelector((session) => session.connected);
const role = useSessionSelector((session) => session.session?.role);
const mode = useSessionSelector((session) => session.session?.mode);
const admin = role === 'admin' || role === 'lockdown';
const allowed = ['user', 'admin', 'lockdown'].includes(role) && (mode !== 'admin' || admin) && (mode !== 'lockdown' || role === 'lockdown');
if (!state?.enabled || !state.items.length) return null;
// Session data survives a browser disconnect. Disable immediately so local
// debounce timers are cancelled even when HA itself remains connected.
const connected = state.connected && socketConnected;
// Reuse the room-control auto-fit grid and compact tile rhythm, while keeping
// all data and commands in the Activities namespace on both device layouts.
return <CardFrame title="Activity Controls" bodyClassName="space-y-0.5 text-sm"
actions={<span className={`rounded px-1 py-0.5 text-xs font-semibold leading-none ${connected ? 'bg-emerald-900 text-emerald-100' : 'bg-amber-900 text-amber-100'}`}>{connected ? 'Connected' : 'Offline'}</span>}>
{!connected ? <p className="px-0.5 text-xs text-amber-200">{socketConnected ? 'Home Assistant is offline.' : 'Server disconnected.'} Values may be out of date.</p> : null}
{!allowed ? <p className="px-0.5 text-xs text-slate-400">Controls are read-only with your current access.</p> : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(9rem,100%),1fr))] gap-0.5">
{state.items.map((entity) => <ActivityTile key={entity.id} entity={entity} connected={connected} allowed={allowed} admin={admin} />)}
</div>
</CardFrame>;
}
@@ -0,0 +1,32 @@
// Number/text drafts are local until the short pause expires. HA broadcasts
// cannot erase unfinished typing. After sending, the reported state owns the UI.
import { useCallback, useEffect, useRef, useState } from 'react';
export default function useDraftControl(onChange, disabled) {
const [draft, setDraft] = useState(null);
const timer = useRef(null);
const cancel = useCallback(() => {
clearTimeout(timer.current);
timer.current = null;
}, []);
// A lock, disconnect, or unmount cancels queued edits. Unlocking must never
// replay a change that was typed before permission was removed.
useEffect(() => cancel, [cancel, disabled]);
const commit = useCallback((value) => {
cancel();
if (disabled) return;
// Sending ends the local edit, not a request/response transaction. The
// next HA broadcast updates this input just like any external change.
onChange(value);
setDraft(null);
}, [onChange, disabled, cancel]);
const edit = (value, debounce = true) => {
cancel();
setDraft(value);
if (debounce && !disabled) timer.current = setTimeout(() => commit(value), 650);
};
return { draft, edit, commit, cancel };
}
@@ -174,7 +174,7 @@ export default function HomeAssistantControls() {
/*
Feature existence is owned here, not by each layout that happens to mount
room controls. Disabled integrations render nothing; enabled integrations
room lights. Disabled integrations render nothing; enabled integrations
can still show offline/configuration states inside the panel.
*/
if (!enabled) return null;
@@ -199,7 +199,7 @@ function HomeAssistantControlsContent() {
if (!ha?.enabled) {
return (
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
<CardFrame title="Room Lights" bodyClassName="space-y-0.5 text-sm text-slate-400">
<p className="text-slate-500">Not configured on the server.</p>
</CardFrame>
);
@@ -207,7 +207,7 @@ function HomeAssistantControlsContent() {
if (entities.length === 0) {
return (
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
<CardFrame title="Room Lights" bodyClassName="space-y-0.5 text-sm text-slate-400">
<p className="text-slate-500">No lights or switches configured.</p>
</CardFrame>
);
@@ -233,16 +233,16 @@ function HomeAssistantControlsContent() {
);
return (
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
<CardFrame title="Room Lights" actions={actions} bodyClassName="space-y-0.5 text-base">
{lightPolicyLocked ? (
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
{adminCanControlLockedLights
? lockState === 'off'
? 'Lights are locked off. Admin room controls remain available.'
: 'Lights are locked on. Admin room controls remain available.'
? 'Lights are locked off. Admin room lights remain available.'
: 'Lights are locked on. Admin room lights remain available.'
: lockState === 'off'
? 'Lights are locked off. Room controls are disabled.'
: 'Lights are locked on. Room controls are disabled.'}
? 'Lights are locked off. Room lights are disabled.'
: 'Lights are locked on. Room lights are disabled.'}
</p>
) : null}
{/* Each lamp declares only the smallest width at which its title, status,
@@ -89,7 +89,7 @@ export default function RoverDescriptionOverlay({
largeFading ? 'opacity-0' : 'opacity-100'
} ${mobileHud ? 'text-[1rem] leading-tight' : 'text-[1.5rem] leading-tight'}`}
>
<p>Just so you know, this rover</p>
{/* <p>Just so you know, this rover</p> */}
<p>{resolvedDescription}</p>
<p className={`${mobileHud ? 'text-[0.58rem]' : 'text-[0.72rem]'} mt-0.5 font-normal text-slate-300`}>
This fades 5 seconds after your first control input.
@@ -189,7 +189,7 @@ export function InterInstancePopup({ onClose }) {
<InterInstanceBrowserFrame
onClose={onClose}
scaledOverlay
className="inter-instance-overlay-frame"
className="inter-instance-overlay-frame w-fit [container-type:normal]"
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
/>
</div>
@@ -36,8 +36,8 @@ const KEY_ACTIONS = [
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
{ id: 'songNoteUp', label: 'Song Note Up', group: 'Audio' },
{ id: 'songNoteDown', label: 'Song Note Down', group: 'Audio' },
{ id: 'homeAssistantOn', label: 'Room Controls On (Cycle)', group: 'Room Controls' },
{ id: 'homeAssistantOff', label: 'Room Controls Off (Cycle)', group: 'Room Controls' },
{ id: 'homeAssistantOn', label: 'Room Lights On (Cycle)', group: 'Room Lights' },
{ id: 'homeAssistantOff', label: 'Room Lights Off (Cycle)', group: 'Room Lights' },
];
function groupActions(actions) {
+3 -3
View File
@@ -556,7 +556,7 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
<PtzControlReference />
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
{/*
Desktop keeps room controls as the final sidebar tool so camera
Desktop keeps room lights as the final sidebar tool so camera
turn controls and replay remain above the less-frequent room-wide
actions. HomeAssistantControls owns its own feature and policy gate.
*/}
@@ -604,7 +604,7 @@ function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
</main>
{/*
The right control column is naturally taller than the viewport.
Placing room controls after the fixed-height video uses that left-
Placing room lights after the fixed-height video uses that left-
column space while the whole landscape page continues scrolling as
one surface.
*/}
@@ -678,7 +678,7 @@ function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
</div>
<PtzQueueSummary ptz={ptz} />
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
{/* Portrait keeps room controls immediately after chat as requested. */}
{/* Portrait keeps room lights immediately after chat as requested. */}
<HomeAssistantControls />
</section>
</div>
+7 -1
View File
@@ -351,6 +351,12 @@ export function SessionProvider({ children }) {
subscribeAll: () => emitWithAck('session:subscribeAll'),
lockRover: (roverId, locked) => emitWithAck('session:lockRover', { roverId, locked }),
setMode: (mode) => emitWithAck('setMode', { mode }),
// Activity controls are driven by HA broadcasts, not acknowledgements.
// Skip disconnected edits instead of buffering and replaying stale
// values when the browser reconnects.
homeAssistantActivityAct: (id, value) => {
if (socket.connected) socket.emit('homeAssistantActivities:act', { id, value });
},
homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }),
homeAssistantSetState: (entityId, state) =>
emitWithAck('homeAssistant:setState', { entityId, state }),
@@ -428,7 +434,7 @@ export function SessionProvider({ children }) {
clearLatestReplay: () =>
setState((prev) => (prev.latestReplay ? { ...prev, latestReplay: null } : prev)),
}),
[emitWithAck, setState],
[emitWithAck, setState, socket],
);
const store = useMemo(
+1 -1
View File
@@ -189,7 +189,7 @@ export function ControlSystemProvider({ children }) {
Automatic drive-mode lighting is convenience behavior for the open room.
A room-light lock is an explicit policy decision, including locked-off,
so this helper must not issue any Home Assistant commands while that
policy is active. Admins can still use the dedicated room controls when
policy is active. Admins can still use the dedicated room lights when
they need to override individual lamps.
*/
if (roomLightsLocked) {
@@ -328,7 +328,7 @@ export default function KeyboardInputManager() {
shortcuts are part of the public room-control surface. Admin sessions are
allowed through when the current site mode would also allow their socket
command, so keyboard behavior matches the server-side authorization and
the clickable Room Controls panel.
the clickable Room Lights panel.
*/
if ((ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) && !latest?.adminCanControlLockedLights) return;
const entities = ha.entities || [];
+5 -5
View File
@@ -112,10 +112,10 @@ export const HELP_CONTENT = {
},
{
id: 'room-controls',
title: 'Room Controls',
title: 'Room Lights',
items: [
{ action: 'homeAssistantOn', label: 'Next room control on' },
{ action: 'homeAssistantOff', label: 'Next room control off (reverse)' },
{ action: 'homeAssistantOn', label: 'Next room light on' },
{ action: 'homeAssistantOff', label: 'Next room light off (reverse)' },
],
},
],
@@ -163,7 +163,7 @@ export const HELP_CONTENT = {
type: 'list',
title: 'More controls',
items: [
'Chat, Activities, VIP, Room Controls, Help, and Settings are below the rover controls.',
'Chat, Activities, VIP, Room Lights, Help, and Settings are below the rover controls.',
],
},
],
@@ -200,7 +200,7 @@ export const HELP_CONTENT = {
type: 'list',
title: 'More controls',
items: [
'Chat, Activities, VIP, Room Controls, Help, and Settings are below the rover controls.',
'Chat, Activities, VIP, Room Lights, Help, and Settings are below the rover controls.',
],
},
],
@@ -21,7 +21,7 @@ export default function MobileTabs() {
<Tab id="chat">Chat</Tab>
<Tab id="activities">Activities</Tab>
<VipTabButton compact />
<Tab id="roomcontrols">Room Controls</Tab>
<Tab id="roomcontrols">Room Lights</Tab>
<Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
@@ -1,4 +1,4 @@
// Mobile Room Controls Tab
// Mobile Room Lights Tab
// Purpose: Owns the concrete mobile room-controls card order.
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import HomeAssistantControls from '../../../../../components/HomeAssistantControls/index.jsx';
@@ -1,6 +1,7 @@
// Driver Activities Tab
// Purpose: Owns the shared desktop/mobile ordering of activity cards.
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import HomeAssistantActivitiesPanel from '../../../../../components/HomeAssistantActivitiesPanel/index.jsx';
import NeatoCard from '../../../../../components/NeatoCard/index.jsx';
import LiftCard from '../../../../../components/LiftCard/index.jsx';
import BalanceBoardPanel from '../../../../../components/BalanceBoardPanel/index.jsx';
@@ -17,12 +18,12 @@ export default function ActivitiesTab() {
<div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard />
<LiftCard />
<HomeAssistantActivitiesPanel />
<BalanceBoardPanel />
<BarcodeGamesPanel />
<OdometerPanel />
<ButtonBoxPanel />
<KinectPanel />
{/* Fleet reports retains its existing terminal position and self-gate. */}
<FleetReportsCard />
</div>
</TabPanel>