green mode slop 1

This commit is contained in:
legop3
2026-08-08 23:59:47 -04:00
parent c8742dbbd6
commit 70f71b2d1e
30 changed files with 547 additions and 98 deletions
+159 -63
View File
@@ -495,6 +495,92 @@ The Firmata toggle backend converts the logical value using `activeLow` before s
Peripheral authors should not write JSON, construct SysEx messages, or manually dispatch control IDs. The proposed `RoverPeripheralFirmata` Arduino library owns those tasks.
A long positional call such as `addRoverCameraServo(14, -15, 30, 0, 2, 900, 2100, false, false)` is deliberately not part of the API. Several adjacent numbers and booleans are too difficult to understand or review without repeatedly consulting the function signature.
The public API uses named configuration structs. Field names include units where a bare number would otherwise be ambiguous, and enums replace booleans whose meaning would be unclear at the call site.
### Proposed configuration types
The core public types are:
```cpp
enum class OutputPolarity {
ActiveHigh,
ActiveLow
};
enum class ButtonMode {
Toggle,
Momentary
};
struct FirmataServoOutput {
uint8_t pin;
};
struct FirmataPwmOutput {
uint8_t pin;
};
struct FirmataDigitalOutput {
uint8_t pin;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
};
struct RoverCameraServoConfig {
uint8_t pin;
float minimumAngleDegrees;
float maximumAngleDegrees;
float homeAngleDegrees = 0;
float nudgeDegrees = 2;
uint16_t minimumPulseMicroseconds = 900;
uint16_t maximumPulseMicroseconds = 2100;
bool allowRawPulse = false;
bool inverted = false;
};
struct RoverDigitalOutputConfig {
uint8_t pin;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
bool initiallyOn = false;
};
struct SliderControlConfig {
String id;
String name;
int minimum;
int maximum;
};
struct ButtonControlConfig {
String id;
String name;
ButtonMode mode;
};
struct NumberControlConfig {
String id;
String name;
int minimum;
int maximum;
};
struct TextControlConfig {
String id;
String name;
size_t maximumLength;
};
```
Defaults cover values that are commonly shared, but required hardware and display values remain explicit. The implementation must validate the completed struct when it is registered rather than assuming that every default-constructed object is usable.
The API uses ordinary field assignments instead of C++ designated initializers. This keeps example sketches compatible with ESP32 Arduino toolchains that are not configured for C++20.
### Generic-control registration
A complete sketch for one servo slider, one light-brightness slider, and one custom momentary button is:
```cpp
@@ -508,9 +594,6 @@ A complete sketch for one servo slider, one light-brightness slider, and one cus
*/
RoverPeripheralFirmata peripheral("Example peripheral");
constexpr uint8_t SERVO_PIN = 14;
constexpr uint8_t LIGHT_PWM_PIN = 18;
/*
* This is ordinary application code rather than Firmata plumbing. A real
* peripheral can replace it with any device-specific sequence or library call.
@@ -526,34 +609,44 @@ void setup() {
* roverd handles this control with standard Firmata SERVO commands. The
* ESP32 application does not need a callback for each slider update.
*/
peripheral.addServoSlider(
"servoPosition",
"Servo position",
SERVO_PIN,
0,
180
);
SliderControlConfig servoPosition;
servoPosition.id = "servoPosition";
servoPosition.name = "Servo position";
servoPosition.minimum = 0;
servoPosition.maximum = 180;
FirmataServoOutput servoOutput;
servoOutput.pin = 14;
peripheral.addServoSlider(servoPosition, servoOutput);
/*
* roverd handles this control with standard Firmata PWM commands. The range
* is included in the generated description and displayed by the web UI.
*/
peripheral.addPwmSlider(
"lightBrightness",
"Light brightness",
LIGHT_PWM_PIN,
0,
255
);
SliderControlConfig lightBrightness;
lightBrightness.id = "lightBrightness";
lightBrightness.name = "Light brightness";
lightBrightness.minimum = 0;
lightBrightness.maximum = 255;
FirmataPwmOutput lightOutput;
lightOutput.pin = 18;
peripheral.addPwmSlider(lightBrightness, lightOutput);
/*
* Custom controls are delivered through the rover-peripheral Firmata feature.
* The library finds this registration by control ID and invokes the callback
* with true on press and false on release.
*/
peripheral.addMomentaryButton(
"specialAction",
"Run special action",
ButtonControlConfig specialAction;
specialAction.id = "specialAction";
specialAction.name = "Run special action";
specialAction.mode = ButtonMode::Momentary;
peripheral.addButton(
specialAction,
[](bool pressed) {
if (pressed) {
runSpecialAction();
@@ -576,40 +669,27 @@ void loop() {
}
```
The intended registration methods are:
The intended generic registration methods are:
```cpp
addServoSlider(id, name, pin, min, max)
addPwmSlider(id, name, pin, min, max)
addDigitalToggle(id, name, pin)
addDigitalMomentaryButton(id, name, pin)
addServoSlider(const SliderControlConfig&, const FirmataServoOutput&)
addPwmSlider(const SliderControlConfig&, const FirmataPwmOutput&)
addDigitalButton(const ButtonControlConfig&, const FirmataDigitalOutput&)
addCustomSlider(id, name, min, max, callback)
addToggle(id, name, callback)
addMomentaryButton(id, name, callback)
addNumber(id, name, min, max, callback)
addText(id, name, maxLength, callback)
addSlider(const SliderControlConfig&, SliderCallback)
addButton(const ButtonControlConfig&, ButtonCallback)
addNumber(const NumberControlConfig&, NumberCallback)
addText(const TextControlConfig&, TextCallback)
```
These helpers all produce the same four UI types. Method names describe the Firmata mapping or callback type; they do not create additional UI control types.
These helpers still produce only the four agreed UI types. The overload or method name distinguishes a standard Firmata output from a custom callback; it does not create an additional UI type.
The standardized built-in replacements use separate methods because they do not create generic UI controls:
```cpp
addRoverCameraServo(
pin,
minAngle,
maxAngle,
homeAngle,
nudgeDegrees,
minPulseUs,
maxPulseUs,
allowRawPulse,
invert
)
addRoverHeadlight(pin, activeLow, initialOn)
addRoverLaser(pin, activeLow, initialOn)
addRoverCameraServo(const RoverCameraServoConfig&)
addRoverHeadlight(const RoverDigitalOutputConfig&)
addRoverLaser(const RoverDigitalOutputConfig&)
```
A laptop GPIO peripheral can combine built-in replacements and additional controls:
@@ -628,29 +708,45 @@ void setup() {
* These declarations satisfy existing rover roles. They retain the normal
* camera, headlight, and laser UI instead of entering the generic column.
*/
peripheral.addRoverCameraServo(
14,
-15,
30,
0,
2,
900,
2100,
false,
false
);
peripheral.addRoverHeadlight(18, false, false);
peripheral.addRoverLaser(19, false, false);
RoverCameraServoConfig cameraServo;
cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15;
cameraServo.maximumAngleDegrees = 30;
cameraServo.homeAngleDegrees = 0;
cameraServo.nudgeDegrees = 2;
cameraServo.minimumPulseMicroseconds = 900;
cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false;
cameraServo.inverted = false;
peripheral.addRoverCameraServo(cameraServo);
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
peripheral.addRoverHeadlight(headlight);
RoverDigitalOutputConfig laser;
laser.pin = 19;
laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false;
peripheral.addRoverLaser(laser);
/*
* This is an additional feature, so it appears below the peripheral heading
* in the ordered generic-control column.
*/
peripheral.addCustomSlider(
"underglowBrightness",
"Underglow brightness",
0,
255,
SliderControlConfig underglowBrightness;
underglowBrightness.id = "underglowBrightness";
underglowBrightness.name = "Underglow brightness";
underglowBrightness.minimum = 0;
underglowBrightness.maximum = 255;
peripheral.addSlider(
underglowBrightness,
[](int brightness) {
setUnderglowBrightness(brightness);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -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-D8T7esRs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
<script type="module" crossorigin src="/assets/index-Bp0VsaHN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DKoVL79I.css">
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,75 @@
// Reward Definition: Green Mode
// Purpose: Enables the server-wide green theme and room effect for twenty minutes.
// Scope: Owns button-box timing/recovery while delegating the actual mode to greenModeService.
const DURATION_MS = 20 * 60 * 1000;
let activeTimer = null;
let unsubscribeGreenMode = null;
function clearRuntimeWatchers() {
if (activeTimer) {
clearTimeout(activeTimer);
activeTimer = null;
}
if (unsubscribeGreenMode) {
unsubscribeGreenMode();
unsubscribeGreenMode = null;
}
}
async function stopGreenMode(ctx) {
clearRuntimeWatchers();
await ctx.setGreenMode(false, { source: 'buttonbox:greenModeExpired' });
ctx.clearEffect('greenMode');
}
async function startGreenMode(ctx, effect = {}) {
clearRuntimeWatchers();
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
const remaining = Math.max(0, endsAt - Date.now());
if (remaining <= 0) {
await stopGreenMode(ctx);
return;
}
await ctx.setGreenMode(true, { source: 'buttonbox:greenMode' });
ctx.saveEffect('greenMode', { endsAt });
/*
Access-mode changes disable green mode through greenModeService. Watching
that shared state transition lets the reward discard its persisted effect
immediately, so a restart cannot accidentally revive a reward that was
intentionally ended early.
*/
unsubscribeGreenMode = ctx.onGreenModeChange((enabled) => {
if (enabled) return;
clearRuntimeWatchers();
ctx.clearEffect('greenMode');
});
activeTimer = setTimeout(() => {
stopGreenMode(ctx).catch((err) => {
ctx.logger.warn('green mode reward stop failed', { error: err.message });
});
}, remaining);
}
module.exports = {
id: 'greenMode',
name: 'Green mode',
description: 'Makes the room and server green for 20 minutes.',
goal: 5,
async run(ctx) {
await startGreenMode(ctx, { endsAt: Date.now() + DURATION_MS });
},
async recover(ctx, effect) {
// Recovery must never manufacture a fresh twenty-minute window from a
// missing or corrupt persisted deadline. Treat it as expired and clean up.
if (!Number.isFinite(Number(effect?.endsAt))) {
await stopGreenMode(ctx);
return;
}
await startGreenMode(ctx, effect);
},
};
@@ -0,0 +1,57 @@
// Green Mode Reward Tests
// Purpose: Pins the five-press metadata and persisted timed-effect lifecycle.
// Scope: Uses a small context double; greenModeService behavior is tested through its public contract.
const test = require('node:test');
const assert = require('node:assert/strict');
const reward = require('./greenMode');
function createContext() {
const calls = [];
let changeListener = null;
return {
calls,
logger: { warn: () => {} },
setGreenMode: async (enabled, options) => {
calls.push({ type: 'set', enabled, source: options?.source });
return enabled;
},
saveEffect: (id, payload) => calls.push({ type: 'save', id, payload }),
clearEffect: (id) => calls.push({ type: 'clear', id }),
onGreenModeChange: (listener) => {
changeListener = listener;
return () => {
changeListener = null;
};
},
emitGreenModeChange: (enabled) => changeListener?.(enabled),
};
}
test('green mode reward requires five presses and starts a persisted effect', async () => {
const ctx = createContext();
assert.equal(reward.goal, 5);
await reward.run(ctx);
assert.deepEqual(ctx.calls[0], { type: 'set', enabled: true, source: 'buttonbox:greenMode' });
const saved = ctx.calls.find((call) => call.type === 'save');
assert.equal(saved?.id, 'greenMode');
assert.ok(saved?.payload?.endsAt > Date.now());
// Simulate an access-mode shutdown so the test also clears the reward's
// twenty-minute timer instead of leaving background work in the test process.
ctx.emitGreenModeChange(false);
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
});
test('invalid recovery state is cleared instead of starting a new duration', async () => {
const ctx = createContext();
await reward.recover(ctx, {});
assert.deepEqual(ctx.calls[0], {
type: 'set',
enabled: false,
source: 'buttonbox:greenModeExpired',
});
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
});
+2
View File
@@ -10,6 +10,7 @@ const discordPingEveryone = require('./definitions/discordPingEveryone');
const modeJam = require('./definitions/modeJam');
const assignmentRoulette = require('./definitions/assignmentRoulette');
const chatSpam = require('./definitions/chatSpam');
const greenMode = require('./definitions/greenMode');
const orderedRewards = [
dockPanic,
@@ -22,6 +23,7 @@ const orderedRewards = [
modeJam,
assignmentRoulette,
chatSpam,
greenMode,
];
const rewardById = new Map(orderedRewards.map((reward, idx) => [reward.id, { ...reward, number: idx + 1 }]));
@@ -22,6 +22,9 @@ function createButtonBoxCore(deps) {
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
setGreenMode,
isGreenModeEnabled,
onGreenModeChange,
store,
} = deps;
@@ -215,6 +218,11 @@ function createButtonBoxCore(deps) {
setHomeAssistantEntityState(entityId, state, { source: 'buttonBoxReward' }),
setHomeAssistantLightsLockedOn: (next, options = {}) =>
setHomeAssistantLightsLockedOn(next, options),
// Rewards receive the standalone feature boundary rather than reaching
// into Home Assistant or duplicating green-mode state and alerts.
setGreenMode: (next, options = {}) => setGreenMode(next, options),
isGreenModeEnabled: () => isGreenModeEnabled(),
onGreenModeChange: (listener) => onGreenModeChange(listener),
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
};
@@ -24,6 +24,7 @@ const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
const { createButtonBoxStore } = require('./store');
const { createButtonBoxCore } = require('./core');
const { registerButtonBoxRoute } = require('./httpRoute');
const greenModeService = require('../greenModeService');
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('buttonbox-state.json');
@@ -61,6 +62,14 @@ const core = createButtonBoxCore({
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
setGreenMode: greenModeService.setEnabled,
isGreenModeEnabled: greenModeService.isEnabled,
// Return an explicit cleanup function so timed rewards can stop observing
// the global service when they expire, rerun, or are recovered.
onGreenModeChange: (listener) => {
greenModeService.greenModeEvents.on('change', listener);
return () => greenModeService.greenModeEvents.off('change', listener);
},
store,
});
@@ -10,6 +10,7 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const greenModeService = require('../greenModeService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const { isFeatureEnabled } = require('../../helpers/features');
@@ -174,6 +175,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,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
@@ -63,6 +63,7 @@ const { subscribe } = require('../eventBus');
const { createPresenceManager } = require('./presence');
const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('../operatorCommandService');
const greenModeService = require('../greenModeService');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations');
const { createFleetDailyReports } = require('./fleetDailyReports');
@@ -240,6 +241,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,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
@@ -0,0 +1,93 @@
// Green Mode Service
// Purpose: Owns the temporary server-wide green visual mode and its tiny light workflow.
// Scope: Composes existing Home Assistant operations; it does not add policy to that service.
const EventEmitter = require('events');
const logger = require('../../globals/logger').child('greenModeService');
const { sendAlert } = require('../alertService');
const homeAssistantService = require('../homeAssistantService');
const { modeEvents } = require('../modeManager');
const GREEN_MODE_COLOR = '#00ff00';
const greenModeEvents = new EventEmitter();
let enabled = false;
function isEnabled() {
return enabled;
}
async function setEnabled(nextValue, options = {}) {
const next = Boolean(nextValue);
if (enabled === next) return enabled;
if (next) {
/*
Lock first because the existing locked-on transition sets lights white.
Recoloring RGB lights afterward leaves them green while retaining the
established room-control lock, idle protection, and laser safety rules.
*/
await homeAssistantService.setLightsLockedOn(true, {
source: String(options?.source || 'greenMode:enable'),
});
const entities = homeAssistantService.getState()?.entities || [];
/*
RGB-capable lights become the requested solid green. Every other
configured room control, including white-only bulbs and switches, is
explicitly turned off so the physical room has one unambiguous effect.
These remain generic Home Assistant calls; that service does not know
that the operations belong to green mode.
*/
const results = await Promise.allSettled(
entities.map((entity) => (
entity?.supportsColor
? homeAssistantService.setLightColor(entity.id, GREEN_MODE_COLOR)
: homeAssistantService.setEntityState(entity.id, 'off', {
source: 'greenMode:non-rgb-off',
})
)),
);
const failures = results
.map((result, index) => ({ result, entityId: entities[index].id }))
.filter(({ result }) => result.status === 'rejected')
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
if (failures.length) {
logger.warn('Some room controls failed to enter green mode', { failures });
}
} else {
// Disabling the visual mode simply releases the lock it created. Bulb
// colors remain untouched, matching the existing one-shot light behavior.
await homeAssistantService.setLightsLockedOn(false, {
source: String(options?.source || 'greenMode:disable'),
});
}
enabled = next;
logger.info('Green mode changed', {
enabled,
source: options?.source || 'unknown',
});
// Emit one shared server alert for every completed transition. Automatic
// access-mode shutdown uses this same function, so clients also receive the
// inactive notice when green mode ends without an explicit chat command.
sendAlert({
color: GREEN_MODE_COLOR,
title: 'Green mode',
message: enabled ? 'Green mode is active.' : 'Green mode is inactive.',
});
greenModeEvents.emit('change', enabled);
return enabled;
}
modeEvents.on('change', () => {
if (!enabled) return;
setEnabled(false, { source: 'modeGateReset' }).catch((err) => {
logger.warn('Failed to disable green mode on access-mode change', err.message);
});
});
module.exports = {
isEnabled,
setEnabled,
greenModeEvents,
};
@@ -0,0 +1,37 @@
// Operator Green Command
// Purpose: Toggles the intentionally silly server-wide green visual and room-light mode.
// Scope: Keeps command presentation here while Home Assistant owns the runtime policy.
const { getCommandConfig } = require('../../operatorCommandService/config');
function createGreenCommand({ greenModeService, sanitizeMentions, config }) {
const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleGreenCommand(message, tokens = []) {
const action = String(tokens.shift() || '').trim().toLowerCase();
if (action !== 'on' && action !== 'off') {
await message.reply({
content: `Invalid green command. Use \`${commandPrefix} green on\` or \`${commandPrefix} green off\`.`,
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
try {
const enabled = action === 'on';
const result = await greenModeService.setEnabled(enabled, {
source: `bot-command:green:${action}`,
});
await message.reply({
content: sanitizeMentions(result ? 'Green mode enabled.' : 'Green mode disabled.'),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to update green mode: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
};
}
module.exports = { createGreenCommand };
@@ -10,6 +10,7 @@ const { createVerifyCommand } = require('./commands/verify');
const { createDeterCommand } = require('./commands/deter');
const { createPermissionsCommand } = require('./commands/permissions');
const { createLightsCommand } = require('./commands/lights');
const { createGreenCommand } = require('./commands/green');
const { createKickCommand } = require('./commands/kick');
const { createLiftCommand } = require('./commands/lift');
const { createNeatoCommand } = require('./commands/neato');
@@ -61,6 +62,7 @@ function createCommandHandlers(deps) {
const handleBridgeCommand = transportHandlers.bridge;
const handleTimeStatusCommand = transportHandlers.timeStatus;
const handleLightsCommand = createLightsCommand(deps);
const handleGreenCommand = createGreenCommand(deps);
const handleKickCommand = createKickCommand(deps);
const handleLiftCommand = createLiftCommand(deps);
const handleNeatoCommand = createNeatoCommand(deps);
@@ -108,7 +110,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', 'kick', 'lift', 'neato']);
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'permissions', 'lights', 'green', 'kick', 'lift', 'neato']);
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
// Feature commands are public activities while access is open or managed
@@ -144,6 +146,8 @@ function createCommandHandlers(deps) {
return handleBridgeCommand(request, tokens);
case 'lights':
return handleLightsCommand(request, tokens);
case 'green':
return handleGreenCommand(request, tokens);
case 'kick':
return handleKickCommand(request, rest);
case 'lift':
@@ -30,6 +30,9 @@ function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
isFeatureEnabled: () => featureEnabled,
sanitizeMentions: (text) => String(text || ''),
homeAssistantService: { getLightPolicyState: () => ({}), setAllControllableEntitiesState: () => Promise.resolve() },
// Green mode is admin-only at the dispatcher, so this double only needs to
// prove that an authorized command reaches the small standalone service.
greenModeService: { setEnabled: (enabled) => Promise.resolve(Boolean(enabled)) },
liftService: null,
neatoService: null,
listVerifiedUsers: () => [],
@@ -72,7 +75,7 @@ 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 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']) {
assert.match(await run(command, nonAdmin), ADMIN_DENIAL, `${command} must stay admin-only`);
}
});
@@ -100,11 +103,22 @@ test('admin mode restricts access-mode feature commands', async () => {
test('lockdown still suspends the pre-existing moderation-sensitive commands', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
for (const command of ['rs lock rover-1', 'rs mode open', 'rs lights on', 'rs goal', 'rs kick alice']) {
for (const command of ['rs lock rover-1', 'rs mode open', 'rs lights on', 'rs green on', 'rs goal', 'rs kick alice']) {
assert.match(await run(command, admin), LOCKDOWN_DENIAL, `${command} should stay lockdown-gated`);
}
});
test('admins can toggle green mode through the shared command path', async () => {
const run = createRouter();
assert.match(await run('rs green on', admin), /Green mode enabled/);
assert.match(await run('rs green off', admin), /Green mode disabled/);
});
test('green mode rejects unknown actions with focused usage guidance', async () => {
const run = createRouter();
assert.match(await run('rs green maybe', admin), /rs green on.*rs green off/);
});
test('status and help survive lockdown', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
assert.match(await run('rs status', nonAdmin), /\[status handler\]/);
@@ -3,7 +3,7 @@
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter', 'permissions'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'green', 'kick', 'verify', 'deter', 'permissions'] },
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
discord: { title: 'Discord', names: ['bridge'] },
};
@@ -19,6 +19,7 @@ function buildCommandRegistry(prefix, timeCommand) {
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
green: { category: 'admin', summary: 'Toggle green room and page mode.', usage: [`${prefix} green <on|off>`], access: 'Admin', permission: 'admin', requiredFeature: 'homeAssistant', unavailableLabel: 'Home Assistant' },
lights: {
category: 'features',
summary: 'Control room lights or manage the admin light lock.',
@@ -17,6 +17,7 @@ const {
ptzCameraEvents,
} = require('../ptzCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
const { isEnabled: isGreenModeEnabled, greenModeEvents } = require('../greenModeService');
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService');
const { getState: getKinectState, kinectEvents } = require('../kinectService');
@@ -199,6 +200,9 @@ function buildSession(socket) {
roomCameras: getRoomCameras(),
ptzCamera: getPtzCameraState(socket),
homeAssistant: getHomeAssistantState(),
// 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(),
neato: getNeatoState(),
lift: getLiftState(),
kinect: getKinectState(),
@@ -391,6 +395,11 @@ homeAssistantEvents.on('update', () => {
syncAll();
});
greenModeEvents.on('change', () => {
logger.info('Green mode change detected; syncing all clients');
syncAll();
});
homeAssistantEvents.on('status', () => {
logger.info('Home Assistant status change; syncing all clients');
syncAll();
+17 -3
View File
@@ -42,6 +42,7 @@ export default function CardFrame({
children,
}) {
const showHeader = !hideHeader && (title || meta != null || actions);
const greenMode = useSessionSelector((state) => Boolean(state.session?.greenMode));
const ownRoverColor = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return null;
@@ -52,11 +53,20 @@ export default function CardFrame({
const accentRgb = hexToRgb(ownRoverColor);
// swap these to toggle rover card border colors stuff
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.3) } : undefined;
// Green mode is global server chrome, so it wins over the assigned rover's
// personal accent while active. Keeping this override in CardFrame makes all
// present and future cards participate without sprinkling mode checks around.
const cardStyle = greenMode
? { borderColor: '#00ff00' }
: accentRgb
? { borderColor: rgba(accentRgb, 0.3) }
: undefined;
// const cardStyle = undefined;
const headerStyle = accentRgb
const headerStyle = greenMode
? { borderColor: '#00ff00' }
: accentRgb
? {
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
// backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.1)} 100%)`,
@@ -85,7 +95,11 @@ export default function CardFrame({
style={headerStyle}
>
<div className="flex min-w-0 items-center gap-0.5">
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-50">{title}</p> : null}
{title ? (
<p className={cx('m-0 text-[0.78rem] font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
{title}
</p>
) : null}
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-200">{meta}</span> : null}
</div>
{actions ? <div className="flex flex-wrap items-center justify-end gap-0.5">{actions}</div> : null}
+2 -2
View File
@@ -23,7 +23,7 @@ import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../../themes/index.js';
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../../themes/index.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
const PTZ_DEFAULT_COLOR = '#38bdf8';
@@ -713,7 +713,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
// controls, and card interiors retain their purpose-built dark backgrounds.
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
useEffect(() => {
// Route-exit cleanup runs after the last render, so retain the latest
+2 -2
View File
@@ -7,7 +7,7 @@ import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
import { useSettingsNamespace } from '../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../themes/index.js';
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../themes/index.js';
import IdentityDatabasePanel from './IdentityDatabasePanel.jsx';
function isLockdownAdminRole(role) {
@@ -23,7 +23,7 @@ export default function DatabaseAdminApp() {
});
// Database cards use the same narrow seams as the driver page, so honoring the shared browser
// preference here keeps the existing route-level background behavior while making it dynamic.
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
const isLockdownAdmin = isLockdownAdminRole(role);
const isLoggedInAdmin = role === 'admin' || isLockdownAdmin;
+2 -2
View File
@@ -20,8 +20,8 @@ import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import DuplicateIdentityOverlay from '../components/DuplicateIdentityOverlay/index.jsx';
import {
DEFAULT_PAGE_THEME_KEY,
getPageThemeClass,
themeGapClass,
usePageThemeClass,
} from '../themes/index.js';
import useLayoutMode from '../hooks/useLayoutMode.js';
import { DriverLayoutProvider } from '../layouts/driver/DriverLayoutContext.jsx';
@@ -35,7 +35,7 @@ function DriverPageRoot() {
});
// Resolve the cookie value through the shared catalog before painting the page. This prevents
// an obsolete or hand-edited key from stripping the background class from every exposed seam.
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
return (
<div className={`${pageBackgroundClass} text-slate-100`}>
+3 -2
View File
@@ -12,7 +12,7 @@ import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
import useFleetReport from '../hooks/useFleetReport.js';
import { isFeatureEnabled } from '../lib/features.js';
import { useSettingsNamespace } from '../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass, themeGapClass } from '../themes/index.js';
import { DEFAULT_PAGE_THEME_KEY, themeGapClass, usePageThemeClass } from '../themes/index.js';
const RANGE_OPTIONS = [
{ label: '24 hours', ms: 24 * 60 * 60 * 1000 },
@@ -261,8 +261,9 @@ export default function FleetReportsApp() {
useUserIdentitySync({ identitySurface: 'passive' });
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'fleetReports'));
const { value: pageSettings } = useSettingsNamespace('page', { backgroundTheme: DEFAULT_PAGE_THEME_KEY });
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
return (
<div className={`${getPageThemeClass(pageSettings?.backgroundTheme)} min-h-screen text-slate-100`}>
<div className={`${pageBackgroundClass} min-h-screen text-slate-100`}>
<SocketConnectionPill />
<main className={`mx-auto flex min-h-screen w-full max-w-[120rem] flex-col ${themeGapClass} p-1`}>
{enabled ? <FullReportContent /> : (
+1
View File
@@ -10,3 +10,4 @@ export {
normalizePageThemeKey,
} from './catalog.js';
export { themeGapClass, themeStackClass } from './layout.js';
export { default as usePageThemeClass } from './usePageThemeClass.js';
+7
View File
@@ -24,3 +24,10 @@
background-color: #000000;
background-image: none;
}
.page-theme-green-mode {
/* The deliberately blunt solid lime canvas is a temporary server mode, not
a selectable personal theme, so it belongs outside the persisted catalog. */
background-color: #00ff00;
background-image: none;
}
+17
View File
@@ -0,0 +1,17 @@
// Active Page Theme Hook
// Purpose: Resolves personal theme settings together with temporary server-owned theme overrides.
// Scope: Keeps page shells unaware of individual modes while preserving the pure catalog helpers.
import { useSessionSelector } from '../context/SessionContext.jsx';
import { getPageThemeClass } from './catalog.js';
export default function usePageThemeClass(backgroundTheme) {
const greenMode = useSessionSelector((state) => Boolean(state.session?.greenMode));
/*
Green mode is a temporary server-wide theme, so it wins at resolution time
without modifying the user's persisted theme selection. Every page consumes
this hook and therefore receives future server theme overrides consistently.
*/
if (greenMode) return 'page-theme page-theme-green-mode';
return getPageThemeClass(backgroundTheme);
}