mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
big overhaul for server and webui feature matching, things default to disabled and disappear from UI when disabled.
This commit is contained in:
+40
-30
@@ -7,12 +7,15 @@ admins:
|
||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||
discord_id: "0987654321"
|
||||
lockdown: true
|
||||
|
||||
timezone: "America/New_York"
|
||||
|
||||
llmCommentary:
|
||||
enabled: false
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
frequency: 120000
|
||||
|
||||
overseerControl:
|
||||
enabled: false
|
||||
# autonomous runs the existing vote-gated loop forever; directAddress only
|
||||
@@ -27,9 +30,12 @@ overseerControl:
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
profileImageUrl: "https://example.com/overseer.png"
|
||||
gateIntervalMs: 2000
|
||||
|
||||
barcodeGames:
|
||||
enabled: false
|
||||
botName: "Barcode Games"
|
||||
profileImageUrl: "https://example.com/barcode-games.png"
|
||||
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
@@ -49,13 +55,16 @@ audioLevels:
|
||||
forwardGain: 1.0
|
||||
|
||||
homeAssistant:
|
||||
enabled: false
|
||||
url: "http://homeassistant.local:8123"
|
||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||
neato:
|
||||
enabled: false
|
||||
# ESPHome device name, used to derive gen3 entities:
|
||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||
device: "neato_vacuum"
|
||||
lift:
|
||||
enabled: false
|
||||
# Two Home Assistant switches controlling lift direction.
|
||||
# Raise sequence: down off -> wait interlockMs -> up on
|
||||
# Lower sequence: up off -> wait interlockMs -> down on
|
||||
@@ -92,17 +101,20 @@ homeAssistant:
|
||||
stateEquals: "toggle"
|
||||
cooldownMs: 1000
|
||||
action: "lightsLockToggle"
|
||||
|
||||
roomCameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
description: "Wide shot of the staging area."
|
||||
url: "http://192.168.0.50/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||
- id: "workshop"
|
||||
name: "Workshop Bench"
|
||||
description: "Shows the workbench and charging docks."
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||
enabled: false
|
||||
cameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
description: "Wide shot of the staging area."
|
||||
url: "http://192.168.0.50/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||
- id: "workshop"
|
||||
name: "Workshop Bench"
|
||||
description: "Shows the workbench and charging docks."
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||
|
||||
kinect:
|
||||
enabled: false
|
||||
@@ -111,6 +123,12 @@ kinect:
|
||||
# camera cache; it only gates browser-requested broadcasts.
|
||||
captureCooldownMs: 10000
|
||||
|
||||
buttonBox:
|
||||
enabled: false
|
||||
|
||||
barcodeScanner:
|
||||
enabled: false
|
||||
|
||||
discord:
|
||||
token: "DISCORD_BOT_TOKEN"
|
||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||
@@ -129,23 +147,15 @@ discord:
|
||||
humanAlertPing: "123456789012345678"
|
||||
|
||||
socials:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
icon: "FaDiscord"
|
||||
color: "#5865F2"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
color: "#29ABE0"
|
||||
- id: "wiki"
|
||||
label: "Wiki"
|
||||
url: "https://wiki.example.com"
|
||||
icon: "FaBook"
|
||||
color: "#475569"
|
||||
- id: "throne"
|
||||
label: "Throne"
|
||||
url: "https://throne.me/yourname"
|
||||
icon: "FaCrown"
|
||||
color: "#334155"
|
||||
enabled: false
|
||||
links:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
icon: "FaDiscord"
|
||||
color: "#5865F2"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
color: "#29ABE0"
|
||||
|
||||
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
@@ -78,8 +78,8 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-Cc6hBKXm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JqEX_oga.css">
|
||||
<script type="module" crossorigin src="/assets/index-DHQ5vi8_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DFtJPk7c.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
6. kick people off rover after 3 consecurtive bump-off attempts
|
||||
7. make signaling more clear for idle skips and idle skip kicks
|
||||
8. add a kick command that disconnects sockets with a message
|
||||
9. fix this:
|
||||
9. add more background gap themes
|
||||
10. fix this:
|
||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function clampRgbChannel(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
@@ -247,6 +249,18 @@ function Leaderboard({ players, ownPlayer }) {
|
||||
);
|
||||
}
|
||||
export default function BarcodeGamesPanel() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'barcodeGames'));
|
||||
|
||||
/*
|
||||
Barcode games depend on the optional scanner station. The panel owns the
|
||||
feature gate so disabled installs do not show empty game voting controls.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <BarcodeGamesPanelContent />;
|
||||
}
|
||||
|
||||
function BarcodeGamesPanelContent() {
|
||||
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeSignatureRef = useRef('');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const FLASH_MS = 420;
|
||||
const REWARD_FLASH_MS = 1200;
|
||||
@@ -20,6 +21,18 @@ const BUTTON_TONES = {
|
||||
};
|
||||
|
||||
export default function ButtonBoxPanel() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'buttonBox'));
|
||||
|
||||
/*
|
||||
The physical button box should vanish by owning its own feature gate. Routes
|
||||
can keep mounting this component without leaking empty reward panels.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <ButtonBoxPanelContent />;
|
||||
}
|
||||
|
||||
function ButtonBoxPanelContent() {
|
||||
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
|
||||
const socket = useSocket();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const COLOR_SWATCHES = Object.freeze([
|
||||
{ id: 'white', label: 'White', hex: '#ffffff', action: 'white' },
|
||||
@@ -169,6 +170,19 @@ function LampTile({ entity, connected, controlsLocked, onToggle, onSetColor, onS
|
||||
}
|
||||
|
||||
export default function HomeAssistantControls() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'homeAssistant'));
|
||||
|
||||
/*
|
||||
Feature existence is owned here, not by each layout that happens to mount
|
||||
room controls. Disabled integrations render nothing; enabled integrations
|
||||
can still show offline/configuration states inside the panel.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <HomeAssistantControlsContent />;
|
||||
}
|
||||
|
||||
function HomeAssistantControlsContent() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
|
||||
|
||||
@@ -195,9 +195,12 @@ function TurnsOverlay({
|
||||
Video switched to preview mode to save bandwidth.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-auto mt-0.5">
|
||||
<SocialButton id="discord" label="Join our Discord while you wait!" layout='inline'/>
|
||||
</div>
|
||||
<SocialButton
|
||||
id="discord"
|
||||
label="Join our Discord while you wait!"
|
||||
layout="inline"
|
||||
className="pointer-events-auto mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -11,8 +11,21 @@ import {
|
||||
normalizeBinaryPayload,
|
||||
normalizeKinectStatus,
|
||||
} from './utils.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
export default function KinectPanel() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'kinect'));
|
||||
|
||||
/*
|
||||
Kinect is optional local hardware. Keep the feature gate inside the panel so
|
||||
disabled installs do not need special cases in every Activities layout.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <KinectPanelContent />;
|
||||
}
|
||||
|
||||
function KinectPanelContent() {
|
||||
const socket = useSocket();
|
||||
const status = useSessionSelector((state) => normalizeKinectStatus(state.session?.kinect));
|
||||
const [activeView, setActiveView] = useState('3d');
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function badgeClass(tone) {
|
||||
if (tone === 'good') return 'bg-emerald-600 text-white';
|
||||
@@ -21,6 +22,18 @@ function positionLabel(value) {
|
||||
}
|
||||
|
||||
export default function LiftCard() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'lift'));
|
||||
|
||||
/*
|
||||
Lift is an optional Home Assistant-backed hardware feature. The card owns
|
||||
that existence check so disabled installs do not need layout-level guards.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <LiftCardContent />;
|
||||
}
|
||||
|
||||
function LiftCardContent() {
|
||||
/*
|
||||
The lift card is a complete Activities-tab feature, so it reads the shared
|
||||
lift state and socket actions directly. Keeping that wiring inside the card
|
||||
|
||||
@@ -78,9 +78,7 @@ export default function ModeGateOverlay() {
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<div className="w-full justify-center items-center">
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!"/>
|
||||
</div>
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!" />
|
||||
You can still use the chat while the server is locked:
|
||||
{/* set max height of this box */}
|
||||
<div className='max-h-80 overflow-y-auto'>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function normalizeState(value) {
|
||||
return String(value || '').trim();
|
||||
@@ -41,6 +42,18 @@ function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel =
|
||||
}
|
||||
|
||||
export default function NeatoCard() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'neato'));
|
||||
|
||||
/*
|
||||
Neato support is optional hardware surfaced through Home Assistant. Keeping
|
||||
the feature gate in this card avoids scattered checks in each route layout.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <NeatoCardContent />;
|
||||
}
|
||||
|
||||
function NeatoCardContent() {
|
||||
/*
|
||||
Neato is a standalone public activity card. It owns its session selector and
|
||||
command actions so callers do not need to know the socket event names or
|
||||
|
||||
@@ -4,6 +4,8 @@ import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { getSocialById } from '../../lib/socials.js';
|
||||
|
||||
function ControlRow({ label, keyLabel }) {
|
||||
return (
|
||||
@@ -45,6 +47,25 @@ function MobileQuickstart() {
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordQuickstartCard() {
|
||||
const discord = useSessionSelector((state) => getSocialById(state, 'discord'));
|
||||
|
||||
/*
|
||||
The section has explanatory text around the actual button, so SocialButton's
|
||||
own null return is not enough here. Keep the whole Discord prompt self-
|
||||
contained so layouts do not need a separate social feature check.
|
||||
*/
|
||||
if (!discord?.url) return null;
|
||||
|
||||
return (
|
||||
<div className="surface p-0.5">
|
||||
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
|
||||
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
|
||||
<SocialButton id="discord" label="Join Discord" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QuickstartOverlay({
|
||||
visible,
|
||||
layout,
|
||||
@@ -85,11 +106,7 @@ export default function QuickstartOverlay({
|
||||
<p className="text-sm font-semibold text-slate-200">Nicknames are assigned randomly by default, you can change yours here.</p>
|
||||
<NicknameForm compact />
|
||||
</div>
|
||||
<div className="surface p-0.5">
|
||||
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
|
||||
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
|
||||
<SocialButton id="discord" label="Join Discord" />
|
||||
</div>
|
||||
<DiscordQuickstartCard />
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-0.5 border-t border-slate-700 px-0.5 py-0.35 text-[0.8rem]">
|
||||
|
||||
@@ -187,6 +187,25 @@ function DriveDockPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function QueueReplayLinksRow() {
|
||||
/*
|
||||
Flex ratios match the old grid proportions when the Links panel exists. If
|
||||
LinkButtonsPanel returns null because socials are disabled, flex naturally
|
||||
removes that item instead of preserving an empty grid column.
|
||||
*/
|
||||
return (
|
||||
<div className={`flex ${themeGapClass}`}>
|
||||
<div className="min-w-0 basis-0 grow-[1]">
|
||||
<RoverQueuesPanel />
|
||||
</div>
|
||||
<div className="min-w-0 basis-0 grow-[0.9]">
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
</div>
|
||||
<LinkButtonsPanel className="min-w-0 basis-0 grow-[0.75]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
const [activeTab, setActiveTab] = useState('telemetry');
|
||||
const chatDockRef = useRef(null);
|
||||
@@ -354,11 +373,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
<TopDownMapPanel />
|
||||
<DriveDockPanel />
|
||||
</div>
|
||||
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,0.75fr)]`}>
|
||||
<RoverQueuesPanel />
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
<LinkButtonsPanel />
|
||||
</div>
|
||||
<QueueReplayLinksRow />
|
||||
{/*
|
||||
This row gets an explicit measured height because the target
|
||||
behavior depends on the row's live viewport position during
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
|
||||
import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
@@ -103,7 +104,19 @@ function useCameraPanelSubscriptionGate() {
|
||||
return { panelRef, isPanelVisible };
|
||||
}
|
||||
|
||||
export default function RoomCameraPanel({
|
||||
export default function RoomCameraPanel(props) {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'roomCameras'));
|
||||
|
||||
/*
|
||||
Room-camera visibility belongs with the room-camera panel. This keeps every
|
||||
route free to mount the panel without duplicating the server feature rule.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <RoomCameraPanelContent {...props} />;
|
||||
}
|
||||
|
||||
function RoomCameraPanelContent({
|
||||
defaultOrientation = 'horizontal',
|
||||
orientation: forcedOrientation,
|
||||
hideLayoutToggle = false,
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
// Purpose: Defines the Social Buttons Grid module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
|
||||
export default function SocialButtonsGrid({ className = '' }) {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'socials'));
|
||||
const socials = useSessionSelector((state) => state.session?.socials ?? []).slice(0, 4);
|
||||
|
||||
if (!socials.length) return null;
|
||||
/*
|
||||
The Links panel owns its feature visibility. Even if the server config has
|
||||
link entries, `socials.enabled: false` makes the server advertise the
|
||||
feature as disabled, and this component disappears completely.
|
||||
*/
|
||||
if (!enabled || !socials.length) return null;
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 grid-rows-2 gap-0.5 ${className}`}>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
@@ -18,9 +19,23 @@ export function NicknameEntryPanel({ compact = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkButtonsPanel() {
|
||||
export function LinkButtonsPanel({ className = '' }) {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'socials'));
|
||||
|
||||
/*
|
||||
This component is the actual Links panel shell. SocialButtonsGrid already
|
||||
hides the buttons when socials are disabled, but the shell must also hide
|
||||
itself so the UI does not leave an empty "Links!" card behind.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<CardFrame title="Links!" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base">
|
||||
<CardFrame
|
||||
title="Links!"
|
||||
fillHeight
|
||||
className={className}
|
||||
bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base"
|
||||
>
|
||||
<SocialButtonsGrid className="flex-1 min-h-0" />
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Feature Helpers
|
||||
// Purpose: Centralizes client-side reads of server-advertised optional features.
|
||||
// Scope: Keeps layout/components from each inventing their own "is this feature configured?" rule.
|
||||
export function isFeatureEnabled(state, featureName) {
|
||||
/*
|
||||
The server owns feature detection because only it can reliably know whether
|
||||
config-driven hardware integrations exist. React should treat missing flags
|
||||
as disabled so old or partial session payloads fail closed and hide extras.
|
||||
*/
|
||||
return Boolean(state?.session?.features?.[featureName]);
|
||||
}
|
||||
|
||||
export function anyFeatureEnabled(state, featureNames = []) {
|
||||
return featureNames.some((featureName) => isFeatureEnabled(state, featureName));
|
||||
}
|
||||
@@ -5,15 +5,11 @@ import RoomCameraPanel from '../../../components/RoomCameraPanel/index.jsx';
|
||||
|
||||
export default function SecondaryRow() {
|
||||
return (
|
||||
<section className="min-h-0">
|
||||
<div className="surface min-h-[14rem] overflow-hidden">
|
||||
<RoomCameraPanel
|
||||
defaultOrientation="horizontal"
|
||||
hideLayoutToggle
|
||||
hideHeader
|
||||
panelId="spectator-secondary"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<RoomCameraPanel
|
||||
defaultOrientation="horizontal"
|
||||
hideLayoutToggle
|
||||
hideHeader
|
||||
panelId="spectator-secondary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user