big overhaul for server and webui feature matching, things default to disabled and disappear from UI when disabled.

This commit is contained in:
legop3
2026-07-05 14:42:43 -04:00
parent 29bf4cc5d2
commit 40e8adf15a
34 changed files with 592 additions and 211 deletions
+98
View File
@@ -0,0 +1,98 @@
// Feature Flags Helper
// Purpose: Normalizes optional server feature availability from config in one place.
// Scope: Keeps hardware/social visibility decisions out of individual UI panels and service callers.
const { loadConfig } = require('./configLoader');
function asBoolean(value, fallback = false) {
/*
Optional feature config is intentionally explicit. A missing `enabled` flag
means "off" for specialty hardware, which makes a fresh public install a
rover-only server until the operator opts into extra devices.
*/
if (typeof value === 'boolean') return value;
return fallback;
}
function asTrimmedString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function getRoomCameraEntries(config) {
const raw = config.roomCameras;
/*
The public config uses `{ enabled, cameras }` so the feature gate is obvious.
Accepting the old array shape here keeps the rest of the server from needing
to know which shape the local config file currently uses.
*/
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === 'object' && Array.isArray(raw.cameras)) return raw.cameras;
return [];
}
function getConfiguredSocials(config) {
/*
Social links have an explicit feature switch. Entries under `links` are just
available data; they do not enable the Links panel by existing.
*/
const links = config.socials && typeof config.socials === 'object' ? config.socials.links : [];
return Array.isArray(links)
? links.filter((entry) => asTrimmedString(entry?.url))
: [];
}
function buildFeatureFlags(config = loadConfig()) {
const homeAssistantConfig = config.homeAssistant || {};
const roomCameraConfig = config.roomCameras || {};
const kinectConfig = config.kinect || {};
const buttonBoxConfig = config.buttonBox || {};
const barcodeScannerConfig = config.barcodeScanner || {};
const barcodeGamesConfig = config.barcodeGames || {};
const socialsConfig = config.socials || {};
const homeAssistant = Boolean(
asBoolean(homeAssistantConfig.enabled) &&
asTrimmedString(homeAssistantConfig.url) &&
asTrimmedString(homeAssistantConfig.token),
);
const roomCameraEntries = getRoomCameraEntries(config);
const roomCamerasEnabled = Array.isArray(config.roomCameras)
? false
: asBoolean(roomCameraConfig.enabled);
const barcodeScanner = asBoolean(barcodeScannerConfig.enabled);
return {
homeAssistant,
roomCameras: Boolean(roomCamerasEnabled && roomCameraEntries.length),
kinect: asBoolean(kinectConfig.enabled),
buttonBox: asBoolean(buttonBoxConfig.enabled),
barcodeScanner,
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
lift: Boolean(
homeAssistant &&
asBoolean(homeAssistantConfig.lift?.enabled) &&
asTrimmedString(homeAssistantConfig.lift?.upSwitch) &&
asTrimmedString(homeAssistantConfig.lift?.downSwitch),
),
neato: Boolean(
homeAssistant &&
asBoolean(homeAssistantConfig.neato?.enabled) &&
asTrimmedString(homeAssistantConfig.neato?.device),
),
socials: Boolean(asBoolean(socialsConfig.enabled) && getConfiguredSocials(config).length > 0),
};
}
function getFeatureFlags() {
return buildFeatureFlags(loadConfig());
}
function isFeatureEnabled(featureName) {
return Boolean(getFeatureFlags()[featureName]);
}
module.exports = {
buildFeatureFlags,
getFeatureFlags,
isFeatureEnabled,
getRoomCameraEntries,
getConfiguredSocials,
};
+40 -27
View File
@@ -6,6 +6,7 @@
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeGameService');
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { subscribe } = require('../eventBus');
const { sendSystemMessage } = require('../chatService');
const { getActiveDrivers } = require('../turnService');
@@ -30,6 +31,7 @@ const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems];
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
const config = loadConfig();
const barcodeGamesConfig = config.barcodeGames || {};
const enabled = isFeatureEnabled('barcodeGames');
const botName = String(barcodeGamesConfig.botName || barcodeGamesConfig.name || 'Barcode Games').trim() || 'Barcode Games';
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
@@ -1120,34 +1122,43 @@ function broadcastState() {
});
}
io.on('connection', (socket) => {
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
socket.join(GAME_SOCKET_ROOM);
const state = buildStatePayload(socket);
socket.emit('barcodeGame:state', state);
cb({ success: true, state });
if (enabled) {
/*
Barcode games are an optional layer on top of the physical scanner station.
Keep sockets and scan subscriptions behind the feature gate so disabled
installs do not run invisible game state.
*/
io.on('connection', (socket) => {
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
socket.join(GAME_SOCKET_ROOM);
const state = buildStatePayload(socket);
socket.emit('barcodeGame:state', state);
cb({ success: true, state });
});
socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => {
try {
cb(setVote(socket, gameId));
} catch (err) {
logger.warn('Barcode game vote failed', { error: err.message, gameId });
cb({ error: err.message || 'barcode game vote failed' });
}
});
});
socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => {
subscribe('barcode.scanned', (event) => {
try {
cb(setVote(socket, gameId));
handleScan(event.payload);
} catch (err) {
logger.warn('Barcode game vote failed', { error: err.message, gameId });
cb({ error: err.message || 'barcode game vote failed' });
// Scanner input should never be able to take down the server. Game failures
// are logged and skipped so the scanner page can keep resolving barcodes.
logger.warn('Barcode game scan handling failed', { error: err.message });
}
});
});
subscribe('barcode.scanned', (event) => {
try {
handleScan(event.payload);
} catch (err) {
// Scanner input should never be able to take down the server. Game failures
// are logged and skipped so the scanner page can keep resolving barcodes.
logger.warn('Barcode game scan handling failed', { error: err.message });
}
});
} else {
logger.info('Barcode games disabled by config');
}
module.exports = {
buildStatePayload,
@@ -1155,8 +1166,10 @@ module.exports = {
setVote,
};
setInterval(() => {
if (settleActiveGameIfNeeded()) {
broadcastState();
}
}, GAME_TICK_MS).unref?.();
if (enabled) {
setInterval(() => {
if (settleActiveGameIfNeeded()) {
broadcastState();
}
}, GAME_TICK_MS).unref?.();
}
@@ -5,6 +5,7 @@ const fs = require('fs');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeScannerService');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isFeatureEnabled } = require('../../helpers/features');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { publishEvent } = require('../eventBus');
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
@@ -14,6 +15,7 @@ const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
const RECENT_SCAN_LIMIT = 8;
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
const SCANNER_SOCKET_ROOM = 'barcode-scanner';
const enabled = isFeatureEnabled('barcodeScanner');
let lastKnownGoodRegistry = null;
let lastRegistryError = null;
@@ -310,39 +312,53 @@ async function applyScan(rawCode) {
return { result };
}
io.on('connection', (socket) => {
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => {
socket.join(SCANNER_SOCKET_ROOM);
socket.emit('barcode:state', buildStatePayload());
cb({ success: true, state: buildStatePayload() });
if (enabled) {
/*
Barcode scanning is tied to a physical scanner station. Disabled installs
should not create the registry file or expose scanner socket commands.
*/
io.on('connection', (socket) => {
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => {
socket.join(SCANNER_SOCKET_ROOM);
socket.emit('barcode:state', buildStatePayload());
cb({ success: true, state: buildStatePayload() });
});
socket.on('barcode:scan', async ({ code } = {}, cb = () => {}) => {
try {
const { result } = await applyScan(code);
cb({ success: true, result, state: buildStatePayload() });
} catch (err) {
// Socket handlers should never let a malformed scan or registry edge case
// bubble out to the process. The page gets a normal failed acknowledgement
// and the service keeps running for the next scan.
logger.warn('Barcode scan failed unexpectedly', err);
cb({ error: err.message || 'barcode scan failed' });
}
});
});
socket.on('barcode:scan', async ({ code } = {}, cb = () => {}) => {
try {
const { result } = await applyScan(code);
cb({ success: true, result, state: buildStatePayload() });
} catch (err) {
// Socket handlers should never let a malformed scan or registry edge case
// bubble out to the process. The page gets a normal failed acknowledgement
// and the service keeps running for the next scan.
logger.warn('Barcode scan failed unexpectedly', err);
cb({ error: err.message || 'barcode scan failed' });
}
modeEvents.on('change', () => {
// Access-mode changes affect whether the scanner page should beep when it
// submits a code, so scanner clients need a fresh state packet even without a
// new scan.
broadcastState();
});
});
modeEvents.on('change', () => {
// Access-mode changes affect whether the scanner page should beep when it
// submits a code, so scanner clients need a fresh state packet even without a
// new scan.
broadcastState();
});
loadRegistryForScan();
loadRegistryForScan();
} else {
logger.info('Barcode scanner disabled by config');
}
module.exports = {
REGISTRY_PATH,
applyScan,
applyScan: (...args) => {
if (!enabled) throw new Error('Barcode scanner is disabled');
return applyScan(...args);
},
buildStatePayload,
getRegistrySnapshot,
getRegistrySnapshot: () => {
if (!enabled) return { registry: null, error: 'barcode scanner disabled' };
return getRegistrySnapshot();
},
};
+34 -14
View File
@@ -4,6 +4,7 @@
const { app } = require('../../globals/http');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('buttonBoxService');
const { isFeatureEnabled } = require('../../helpers/features');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { publishEvent } = require('../eventBus');
const { getRewardById, listRewards } = require('../../rewards');
@@ -28,6 +29,7 @@ const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('buttonbox-state.json');
const BUTTON_COUNT = 4;
const STORE_VERSION = 1;
const enabled = isFeatureEnabled('buttonBox');
const store = createButtonBoxStore({
logger,
@@ -62,21 +64,39 @@ const core = createButtonBoxCore({
store,
});
registerButtonBoxRoute({
app,
logger,
buttonCount: BUTTON_COUNT,
normalizeIp,
isLocalNetwork,
applyPress: core.applyPress,
});
if (enabled) {
/*
The button box is physical local hardware, so disabled public installs
should not expose its LAN-only press endpoint or initialize its reward file.
*/
registerButtonBoxRoute({
app,
logger,
buttonCount: BUTTON_COUNT,
normalizeIp,
isLocalNetwork,
applyPress: core.applyPress,
});
store.loadState();
core.recoverEffects().catch((err) => {
logger.warn('Button box effect recovery failed', err.message);
});
store.loadState();
core.recoverEffects().catch((err) => {
logger.warn('Button box effect recovery failed', err.message);
});
} else {
logger.info('Button box disabled by config');
}
module.exports = {
getButtonBoxState: store.getStateClone,
addButtonBoxCount: core.addCount,
getButtonBoxState: () => {
/*
Session sync still includes a buttonBox key for a stable payload shape,
but disabled mode must not create/read the persisted button-box store.
*/
if (!enabled) return { buttons: [] };
return store.getStateClone();
},
addButtonBoxCount: (...args) => {
if (!enabled) throw new Error('Button box is disabled');
return core.addCount(...args);
},
};
@@ -3,6 +3,7 @@
// Scope: Exposes stable room-control APIs while delegating internals to focused modules.
const logger = require('../../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { events } = require('./state');
const { createRuntimeEngine } = require('./runtimeEngine');
const { createTransport } = require('./transport');
@@ -10,7 +11,7 @@ const { registerHomeAssistantHooks } = require('./hooks');
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const enabled = Boolean(haConfig?.url && haConfig?.token);
const enabled = isFeatureEnabled('homeAssistant');
let callHomeAssistantServiceImpl = async () => {
throw new Error('Home Assistant not connected');
@@ -35,18 +36,33 @@ callHomeAssistantServiceImpl = transport.callHomeAssistantService;
runtimeEngine.loadEntityConfig();
runtimeEngine.loadTriggerConfig();
transport.connect();
registerHomeAssistantHooks({
logger,
haConfig,
isLightControlLocked: runtimeEngine.isLightControlLocked,
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleEntity: runtimeEngine.toggleEntity,
setEntityState: runtimeEngine.setEntityState,
setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite,
});
if (enabled) {
/*
Loading the module should be harmless on rover-only installs. Only connect
to Home Assistant when the central feature gate says the integration exists,
so placeholder URLs/tokens in example config cannot start network traffic.
*/
transport.connect();
}
if (enabled) {
/*
Socket routes are part of the visible Home Assistant feature. Register them
only when enabled so disabled installs do not expose hidden controls that
the UI has intentionally removed.
*/
registerHomeAssistantHooks({
logger,
haConfig,
isLightControlLocked: runtimeEngine.isLightControlLocked,
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleEntity: runtimeEngine.toggleEntity,
setEntityState: runtimeEngine.setEntityState,
setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite,
});
}
module.exports = {
getState: runtimeEngine.getState,
+40 -28
View File
@@ -5,6 +5,7 @@ const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('liftService');
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService');
const {
@@ -19,6 +20,7 @@ const events = new EventEmitter();
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const liftConfig = haConfig.lift || {};
const featureEnabled = isFeatureEnabled('lift');
const upSwitchId = String(liftConfig.upSwitch || '').trim();
const downSwitchId = String(liftConfig.downSwitch || '').trim();
@@ -71,7 +73,7 @@ function getState() {
const configured = isConfigured();
const connected = isHomeAssistantConnected();
return {
enabled: Boolean(homeAssistantEnabled && configured),
enabled: Boolean(featureEnabled && homeAssistantEnabled && configured),
configured,
connected,
entities: {
@@ -102,6 +104,7 @@ function emitUpdate() {
}
function assertReady() {
if (!featureEnabled) throw new Error('Lift is disabled');
if (!isConfigured()) throw new Error('Lift not configured');
if (!homeAssistantEnabled) throw new Error('Home Assistant not configured');
if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected');
@@ -173,38 +176,47 @@ async function moveDown(actor = 'unknown') {
return requestPosition('down', actor);
}
homeAssistantEvents.on('snapshot', emitUpdate);
homeAssistantEvents.on('status', emitUpdate);
if (featureEnabled) {
/*
Lift state depends on Home Assistant switch snapshots. Subscribe only when
the lift exists so disabled installs do not maintain hardware-specific UI
sync paths.
*/
homeAssistantEvents.on('snapshot', emitUpdate);
homeAssistantEvents.on('status', emitUpdate);
io.on('connection', (socket) => {
socket.on('lift:up', async (_, cb = () => {}) => {
try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
io.on('connection', (socket) => {
socket.on('lift:up', async (_, cb = () => {}) => {
try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
}
// Lift movement is now a public activity feature. Lockdown still wins
// above because that mode is the global safety/admin gate for the room.
const resp = await moveUp(socket.id || 'socket');
cb({ success: true, ...resp });
} catch (err) {
cb({ error: err.message });
}
// Lift movement is now a public activity feature. Lockdown still wins
// above because that mode is the global safety/admin gate for the room.
const resp = await moveUp(socket.id || 'socket');
cb({ success: true, ...resp });
} catch (err) {
cb({ error: err.message });
}
});
});
socket.on('lift:down', async (_, cb = () => {}) => {
try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
socket.on('lift:down', async (_, cb = () => {}) => {
try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
}
// Public access intentionally mirrors lift:up so both directions share
// the same policy and cannot drift into different permission behavior.
const resp = await moveDown(socket.id || 'socket');
cb({ success: true, ...resp });
} catch (err) {
cb({ error: err.message });
}
// Public access intentionally mirrors lift:up so both directions share
// the same policy and cannot drift into different permission behavior.
const resp = await moveDown(socket.id || 'socket');
cb({ success: true, ...resp });
} catch (err) {
cb({ error: err.message });
}
});
});
});
} else {
logger.info('Lift disabled by config');
}
emitUpdate();
+25 -9
View File
@@ -5,6 +5,7 @@ const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('neatoService');
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService');
@@ -20,6 +21,7 @@ const events = new EventEmitter();
const config = loadConfig();
const haConfig = config.homeAssistant || {};
const neatoConfig = haConfig.neato || {};
const featureEnabled = isFeatureEnabled('neato');
function normalizeDeviceName(value) {
const raw = String(value || '').trim().toLowerCase();
@@ -117,7 +119,7 @@ function buildState() {
const requiredIds = requiredEntityIds();
const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id));
const connected = Boolean(haConnected && entitiesAvailable);
const enabled = Boolean(homeAssistantEnabled && configured);
const enabled = Boolean(featureEnabled && homeAssistantEnabled && configured);
const controls = {
start: {
@@ -189,15 +191,25 @@ function emitUpdate() {
}
}
homeAssistantEvents.on('snapshot', () => {
emitUpdate();
});
if (featureEnabled) {
/*
Neato telemetry is derived from Home Assistant entities. Disabled installs
should keep the exported API inert instead of tracking HA snapshots for a
robot vacuum feature that does not exist on that server.
*/
homeAssistantEvents.on('snapshot', () => {
emitUpdate();
});
homeAssistantEvents.on('status', () => {
emitUpdate();
});
homeAssistantEvents.on('status', () => {
emitUpdate();
});
}
function assertConfiguredAndConnected() {
if (!featureEnabled) {
throw new Error('Neato is disabled');
}
if (!device) {
throw new Error('Neato not configured');
}
@@ -255,7 +267,8 @@ function hasVerifiedSockets() {
return false;
}
io.on('connection', (socket) => {
if (featureEnabled) {
io.on('connection', (socket) => {
function assertLockdownAccess() {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
@@ -319,7 +332,10 @@ io.on('connection', (socket) => {
cb({ error: err.message });
}
});
});
});
} else {
logger.info('Neato disabled by config');
}
emitUpdate();
@@ -4,6 +4,7 @@
const EventEmitter = require('events');
const logger = require('../../globals/logger').child('roomCameraService');
const { loadConfig } = require('../../helpers/configLoader');
const { getRoomCameraEntries } = require('../../helpers/features');
const events = new EventEmitter();
const config = loadConfig();
@@ -40,7 +41,7 @@ function getRoomCamera(id) {
function loadFromConfig() {
cameraMap.clear();
const list = Array.isArray(config.roomCameras) ? config.roomCameras : [];
const list = getRoomCameraEntries(config);
list.forEach((camera) => {
const normalized = normalizeCamera(camera);
if (normalized) cameraMap.set(normalized.id, normalized);
+25 -9
View File
@@ -5,18 +5,34 @@ const { loadFromConfig, getRoomCameras, getRoomCamera, roomCameraEvents } = requ
const { createSnapshotEngine } = require('./snapshotEngine');
const { registerRoomCameraSocketGateway } = require('./socketGateway');
const replay = require('../replayEngineV2/roomCameraReplayBuilder');
const { isFeatureEnabled } = require('../../helpers/features');
const enabled = isFeatureEnabled('roomCameras');
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
snapshotEngine.startAll();
if (enabled) {
/*
Room cameras are optional local hardware/network devices. The service module
can still be imported by replay, health, and session code, but disabled
installs must not start polling LAN cameras in the background.
*/
loadFromConfig();
snapshotEngine.startAll();
}
registerRoomCameraSocketGateway({
getRoomCamera,
getRoomCameras,
getRoomCameraState: snapshotEngine.getRoomCameraState,
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents,
});
loadFromConfig();
if (enabled) {
/*
Camera frame sockets are part of the room-camera feature surface. Keeping
them behind the same gate prevents disabled features from being callable by
hand even though server/index.js still imports this module.
*/
registerRoomCameraSocketGateway({
getRoomCamera,
getRoomCameras,
getRoomCameraState: snapshotEngine.getRoomCameraState,
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents,
});
}
function buildRoomCameraReplayVideo(options = {}) {
return replay.buildRoomCameraReplayVideo(options, { getRoomCamera, getRoomCameras });
@@ -2,12 +2,13 @@
// Purpose: Defines timing and static social/config constants used by session synchronization behavior.
// Scope: Keeps runtime behavior unchanged while isolating constants from orchestration logic.
const { loadConfig } = require('../../helpers/configLoader');
const { getConfiguredSocials } = require('../../helpers/features');
const config = loadConfig();
const discordInvite = config.discord?.invite || null;
const kofiLink = config.kofi?.link || null;
const serverTimezone = config.timezone || null;
const configuredSocials = Array.isArray(config.socials) ? config.socials : null;
const configuredSocials = getConfiguredSocials(config);
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000;
+9 -7
View File
@@ -32,6 +32,7 @@ const { getGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason } = require('../adminReasonService');
const { subscribe } = require('../eventBus');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const { getFeatureFlags } = require('../../helpers/features');
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
const { getButtonBoxState } = require('../buttonBoxService');
@@ -70,6 +71,7 @@ function buildUserEntry(socket) {
function buildSession(socket) {
const overseerVote = getOverseerVoteStatus();
const features = getFeatureFlags();
const users = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock))
.filter(Boolean)
@@ -82,18 +84,18 @@ function buildSession(socket) {
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId);
const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket);
const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket);
const socials =
configuredSocials?.length
? configuredSocials
: [
...(discordInvite ? [{ id: 'discord', label: 'Discord', url: discordInvite }] : []),
...(kofiLink ? [{ id: 'kofi', label: 'Ko-fi', url: kofiLink }] : []),
];
const socials = features.socials && configuredSocials?.length ? configuredSocials : [];
return {
socketId: socket?.id || null,
role: getRole(socket),
mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
/*
Features is the single UI contract for optional server capabilities. A
disabled feature should be absent from navigation/layout decisions even
though the service module may still be loaded on the Node side.
*/
features,
roster,
odometers: roverManager.getOdometersForSocket(socket),
assignment: {