rename old room controls to lights and rename new thing to activity controls
Container image / image (push) Failing after 1m2s

This commit is contained in:
legop3
2026-09-16 14:45:09 -04:00
parent 51b42ea778
commit 487666eed8
21 changed files with 48 additions and 47 deletions
@@ -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()) {
@@ -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;
@@ -12,7 +12,7 @@ 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('Home Assistant activities are disabled');
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');
@@ -129,7 +129,8 @@ test('locks are process-local and names resolve without guessing', () => {
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/);
assert.throws(() => resolveItem(items, 'Fan'), /No activity/);
// 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 () => {
@@ -1,4 +1,4 @@
// Home Assistant activities have their own catalog so room-light bulk actions
// 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');
@@ -7,13 +7,13 @@ module.exports = {
feature: true,
defaultValue: { enabled: false, items: [] },
schema: strictObject({
enabled: boolean({ description: 'Shows the separate Activities card using the enabled Home Assistant connection.' }),
enabled: boolean({ description: 'Shows the Activity Controls card in Activities using the enabled Home Assistant connection.' }),
items: {
type: 'array',
title: 'Activity items',
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 Activities.', pattern: '^[a-z0-9_]+\\.[a-z0-9_]+$', maxLength: 255, examples: ['input_number.fan_speed'] }),
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.
@@ -21,7 +21,7 @@ module.exports = {
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 item.', required: ['id'] }),
}, { description: 'One independently controlled or read-only activity control item.', required: ['id'] }),
},
}, { title: 'Home Assistant activities', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }),
}, { title: 'Activity Controls', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }),
};
@@ -19,7 +19,7 @@ function resolveItem(items, query) {
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 item matches that name or entity ID');
if (!matches.length) throw new Error('No activity control item matches that name or entity ID');
return matches[0];
}
@@ -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');
+1 -1
View File
@@ -19,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',
});
@@ -5,12 +5,12 @@ 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('Home Assistant activities are unavailable.');
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 items configured.');
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>.`);
@@ -24,7 +24,7 @@ function buildCommandRegistry(prefix, timeCommand) {
// 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 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: 'Home Assistant activities' },
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.',
@@ -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' },
@@ -51,7 +51,7 @@ export default function HomeAssistantActivitiesPanel() {
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="Home Assistant" bodyClassName="space-y-0.5 text-sm"
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}
@@ -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,
@@ -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>
+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';