mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
buttonbox
This commit is contained in:
@@ -80,6 +80,7 @@
|
|||||||
- Finished major `llmCommentaryService` decomposition by extracting tick scheduling, run-loop orchestration, and history-reset behavior into `llmCommentaryService/runner.js`; `llmCommentaryService/index.js` is now a thin composition layer.
|
- Finished major `llmCommentaryService` decomposition by extracting tick scheduling, run-loop orchestration, and history-reset behavior into `llmCommentaryService/runner.js`; `llmCommentaryService/index.js` is now a thin composition layer.
|
||||||
- Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules.
|
- Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules.
|
||||||
- Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer.
|
- Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer.
|
||||||
|
- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer.
|
||||||
|
|
||||||
## WebUI frontend
|
## WebUI frontend
|
||||||
### BIGGEST OFFENDERS
|
### BIGGEST OFFENDERS
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// button Box Service core
|
||||||
|
// Purpose: Handles reward execution, effect persistence/recovery, and press application workflows.
|
||||||
|
// Scope: Keeps runtime behavior unchanged while isolating domain logic from HTTP transport wiring.
|
||||||
|
function createButtonBoxCore(deps) {
|
||||||
|
const {
|
||||||
|
io,
|
||||||
|
logger,
|
||||||
|
getRewardById,
|
||||||
|
roverManager,
|
||||||
|
issueCommand,
|
||||||
|
sendAlert,
|
||||||
|
publishEvent,
|
||||||
|
sendExternalTyping,
|
||||||
|
sendExternalMessage,
|
||||||
|
sendSystemMessage,
|
||||||
|
getMode,
|
||||||
|
setMode,
|
||||||
|
getAdminReason,
|
||||||
|
setAdminReason,
|
||||||
|
clearAdminReason,
|
||||||
|
assignmentService,
|
||||||
|
getHomeAssistantState,
|
||||||
|
setHomeAssistantEntityState,
|
||||||
|
setHomeAssistantLightsLockedOn,
|
||||||
|
store,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function publishUpdated() {
|
||||||
|
publishEvent({
|
||||||
|
source: 'buttonBox',
|
||||||
|
type: 'buttonBox.updated',
|
||||||
|
payload: { updatedAt: Date.now() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEffect(effectId, payload = {}, options = {}) {
|
||||||
|
const state = store.getState();
|
||||||
|
state.effects = state.effects && typeof state.effects === 'object' ? state.effects : {};
|
||||||
|
state.effects[effectId] = payload;
|
||||||
|
store.writeState();
|
||||||
|
if (options.broadcast) {
|
||||||
|
publishUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEffect(effectId, options = {}) {
|
||||||
|
const state = store.getState();
|
||||||
|
if (state.effects && typeof state.effects === 'object' && state.effects[effectId]) {
|
||||||
|
delete state.effects[effectId];
|
||||||
|
store.writeState();
|
||||||
|
if (options.broadcast) {
|
||||||
|
publishUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRewardContext() {
|
||||||
|
return {
|
||||||
|
logger,
|
||||||
|
issueCommand,
|
||||||
|
listOnlineRovers: () => roverManager.getRoster(),
|
||||||
|
sendAlert,
|
||||||
|
publishEvent,
|
||||||
|
sendExternalTyping,
|
||||||
|
sendExternalMessage,
|
||||||
|
sendSystemMessage,
|
||||||
|
getMode,
|
||||||
|
setMode: (mode, source = 'buttonbox') =>
|
||||||
|
setMode(
|
||||||
|
mode,
|
||||||
|
{ data: { role: 'admin', user: { username: source } } },
|
||||||
|
{ force: true },
|
||||||
|
),
|
||||||
|
getAdminReasonText: () => getAdminReason()?.text || null,
|
||||||
|
setAdminReason: (text, source = 'buttonbox') => setAdminReason(text, { by: source }),
|
||||||
|
clearAdminReason: (source = 'buttonbox') => clearAdminReason({ by: source }),
|
||||||
|
rerollAssignments: () => assignmentService.rerollAssignments(),
|
||||||
|
getHomeAssistantEntities: () => {
|
||||||
|
const entities = getHomeAssistantState()?.entities;
|
||||||
|
return Array.isArray(entities) ? entities : [];
|
||||||
|
},
|
||||||
|
getHomeAssistantLightPolicy: () => getHomeAssistantState()?.lightPolicy || null,
|
||||||
|
setHomeAssistantEntityState,
|
||||||
|
setHomeAssistantLightsLockedOn: (next, options = {}) =>
|
||||||
|
setHomeAssistantLightsLockedOn(next, options),
|
||||||
|
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
|
||||||
|
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRewardForButton(button) {
|
||||||
|
const reward = getRewardById(button.rewardId);
|
||||||
|
if (!reward) {
|
||||||
|
store.assignNewReward(button);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const completedCount = Number.isFinite(button.count) ? button.count : 0;
|
||||||
|
const completedGoal = Number.isFinite(button.goal) ? button.goal : 0;
|
||||||
|
const ctx = buildRewardContext();
|
||||||
|
try {
|
||||||
|
await reward.run(ctx);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Reward execution failed', { rewardId: reward.id, error: err.message });
|
||||||
|
}
|
||||||
|
io.emit('buttonBox:rewardRun', {
|
||||||
|
buttonId: button.id,
|
||||||
|
rewardId: reward.id,
|
||||||
|
rewardName: reward.name || reward.id,
|
||||||
|
count: completedCount,
|
||||||
|
goal: completedGoal,
|
||||||
|
ts: Date.now(),
|
||||||
|
});
|
||||||
|
button.lastRewardAt = Date.now();
|
||||||
|
button.count = 0;
|
||||||
|
store.assignNewReward(button, { excludeRewardId: reward.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPress(buttonId) {
|
||||||
|
const state = store.getState();
|
||||||
|
const button = state.buttons.find((entry) => entry.id === buttonId);
|
||||||
|
if (!button) {
|
||||||
|
throw new Error('Unknown button');
|
||||||
|
}
|
||||||
|
|
||||||
|
button.count += 1;
|
||||||
|
button.lastIncrementAt = Date.now();
|
||||||
|
store.writeState();
|
||||||
|
|
||||||
|
io.emit('buttonBox:increment', {
|
||||||
|
buttonId,
|
||||||
|
count: button.count,
|
||||||
|
ts: button.lastIncrementAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (button.count >= button.goal) {
|
||||||
|
await runRewardForButton(button);
|
||||||
|
store.writeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
publishUpdated();
|
||||||
|
return store.clone(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverEffects() {
|
||||||
|
const state = store.getState();
|
||||||
|
const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {};
|
||||||
|
const ctx = buildRewardContext();
|
||||||
|
|
||||||
|
for (const [effectId, payload] of Object.entries(effects)) {
|
||||||
|
const reward = getRewardById(effectId);
|
||||||
|
if (!reward || typeof reward.recover !== 'function') {
|
||||||
|
clearEffect(effectId, { broadcast: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await reward.recover(ctx, payload);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Effect recovery failed', { effectId, error: err.message });
|
||||||
|
clearEffect(effectId, { broadcast: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyPress,
|
||||||
|
recoverEffects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createButtonBoxCore,
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// button Box Service HTTP route
|
||||||
|
// Purpose: Registers button-box press endpoint and request validation against local-network policy.
|
||||||
|
// Scope: Keeps runtime behavior unchanged while isolating transport/validation from domain logic.
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
function registerButtonBoxRoute(deps) {
|
||||||
|
const {
|
||||||
|
app,
|
||||||
|
logger,
|
||||||
|
buttonCount,
|
||||||
|
getRequestIp,
|
||||||
|
normalizeIp,
|
||||||
|
isLocalNetwork,
|
||||||
|
applyPress,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
function parseButtonId(body) {
|
||||||
|
if (typeof body !== 'string') return null;
|
||||||
|
const value = Number.parseInt(body.trim(), 10);
|
||||||
|
if (Number.isFinite(value)) return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function denyIfNotLocal(req, res) {
|
||||||
|
const ip = normalizeIp(getRequestIp(req));
|
||||||
|
if (isLocalNetwork(ip)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
logger.warn('Rejected non-local button press request', { ip: ip || null });
|
||||||
|
res.status(403).json({ error: 'Button presses must originate from local network' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => {
|
||||||
|
if (denyIfNotLocal(req, res)) return;
|
||||||
|
const buttonId = parseButtonId(req.body);
|
||||||
|
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > buttonCount) {
|
||||||
|
res.status(400).json({ error: 'button must be 1-4' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const button = await applyPress(buttonId);
|
||||||
|
res.json({ success: true, button });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message || 'Button processing failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerButtonBoxRoute,
|
||||||
|
};
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
// button Box Service
|
// button Box Service
|
||||||
// Purpose: Defines the button Box Service module and the helpers/state used by this service unit.
|
// Purpose: Composes button-box storage, core reward workflows, and HTTP route wiring.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Keeps runtime behavior unchanged while making this entrypoint a thin composition layer.
|
||||||
const fs = require('fs');
|
|
||||||
const express = require('express');
|
|
||||||
const { app } = require('../../globals/http');
|
const { app } = require('../../globals/http');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('buttonBoxService');
|
const logger = require('../../globals/logger').child('buttonBoxService');
|
||||||
@@ -22,325 +20,63 @@ const {
|
|||||||
setLightsLockedOn: setHomeAssistantLightsLockedOn,
|
setLightsLockedOn: setHomeAssistantLightsLockedOn,
|
||||||
} = require('../homeAssistantService');
|
} = require('../homeAssistantService');
|
||||||
const { getRequestIp, isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
const { getRequestIp, isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
||||||
|
const { createButtonBoxStore } = require('./store');
|
||||||
|
const { createButtonBoxCore } = require('./core');
|
||||||
|
const { registerButtonBoxRoute } = require('./httpRoute');
|
||||||
|
|
||||||
const DATA_DIR = resolveDataDir();
|
const DATA_DIR = resolveDataDir();
|
||||||
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
||||||
const BUTTON_COUNT = 4;
|
const BUTTON_COUNT = 4;
|
||||||
const STORE_VERSION = 1;
|
const STORE_VERSION = 1;
|
||||||
|
|
||||||
let state = null;
|
const store = createButtonBoxStore({
|
||||||
|
logger,
|
||||||
function createDefaultButton(id) {
|
getRewardById,
|
||||||
return {
|
listRewards,
|
||||||
id,
|
dataDir: DATA_DIR,
|
||||||
count: 0,
|
storePath: STORE_PATH,
|
||||||
rewardId: null,
|
buttonCount: BUTTON_COUNT,
|
||||||
rewardName: null,
|
storeVersion: STORE_VERSION,
|
||||||
rewardNumber: null,
|
|
||||||
goal: null,
|
|
||||||
lastIncrementAt: null,
|
|
||||||
lastRewardAt: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultState() {
|
|
||||||
return {
|
|
||||||
version: STORE_VERSION,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
buttons: Array.from({ length: BUTTON_COUNT }, (_, idx) => createDefaultButton(idx + 1)),
|
|
||||||
effects: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clone(value) {
|
|
||||||
return JSON.parse(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLoaded(raw = {}) {
|
|
||||||
const base = createDefaultState();
|
|
||||||
const sourceButtons = Array.isArray(raw.buttons) ? raw.buttons : [];
|
|
||||||
const buttons = base.buttons.map((button, idx) => {
|
|
||||||
const loaded = sourceButtons[idx] || {};
|
|
||||||
return {
|
|
||||||
...button,
|
|
||||||
count: Number.isFinite(loaded.count) ? Math.max(0, Math.floor(loaded.count)) : 0,
|
|
||||||
rewardId: typeof loaded.rewardId === 'string' ? loaded.rewardId : null,
|
|
||||||
rewardName: typeof loaded.rewardName === 'string' ? loaded.rewardName : null,
|
|
||||||
rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null,
|
|
||||||
goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null,
|
|
||||||
lastIncrementAt: Number.isFinite(loaded.lastIncrementAt) ? loaded.lastIncrementAt : null,
|
|
||||||
lastRewardAt: Number.isFinite(loaded.lastRewardAt) ? loaded.lastRewardAt : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const effects = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
|
|
||||||
return {
|
|
||||||
version: STORE_VERSION,
|
|
||||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
|
||||||
buttons,
|
|
||||||
effects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeState() {
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
||||||
const next = {
|
|
||||||
...state,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
||||||
fs.writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
||||||
fs.renameSync(tempPath, STORE_PATH);
|
|
||||||
state = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadState() {
|
|
||||||
if (state) return state;
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
|
||||||
state = normalizeLoaded(raw);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code !== 'ENOENT') {
|
|
||||||
logger.warn('Failed to load button box store', err.message);
|
|
||||||
}
|
|
||||||
state = createDefaultState();
|
|
||||||
}
|
|
||||||
ensureRewardAssignments();
|
|
||||||
writeState();
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureRewardAssignments() {
|
|
||||||
if (!state) return;
|
|
||||||
const seen = new Set();
|
|
||||||
state.buttons.forEach((button) => {
|
|
||||||
const reward = getRewardById(button.rewardId);
|
|
||||||
if (reward && !seen.has(reward.id)) {
|
|
||||||
seen.add(reward.id);
|
|
||||||
button.rewardName = reward.name || null;
|
|
||||||
button.rewardNumber = reward.number;
|
|
||||||
button.goal = reward.goal;
|
|
||||||
if (!Number.isFinite(button.count) || button.count < 0) {
|
|
||||||
button.count = 0;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
assignNewReward(button);
|
|
||||||
if (button.rewardId) {
|
|
||||||
seen.add(button.rewardId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignNewReward(button, options = {}) {
|
|
||||||
const allRewards = listRewards();
|
|
||||||
if (!allRewards.length) throw new Error('No rewards configured');
|
|
||||||
const excludeRewardId = typeof options.excludeRewardId === 'string' ? options.excludeRewardId : null;
|
|
||||||
|
|
||||||
const usedByOtherButtons = new Set(
|
|
||||||
state.buttons
|
|
||||||
.filter((entry) => entry.id !== button.id)
|
|
||||||
.map((entry) => entry.rewardId)
|
|
||||||
.filter((rewardId) => typeof rewardId === 'string' && rewardId.length > 0),
|
|
||||||
);
|
|
||||||
const uniqueCandidates = allRewards.filter(
|
|
||||||
(reward) => !usedByOtherButtons.has(reward.id) && (!excludeRewardId || reward.id !== excludeRewardId),
|
|
||||||
);
|
|
||||||
const fallbackUniqueCandidates = allRewards.filter((reward) => !usedByOtherButtons.has(reward.id));
|
|
||||||
const pool = uniqueCandidates.length
|
|
||||||
? uniqueCandidates
|
|
||||||
: fallbackUniqueCandidates.length
|
|
||||||
? fallbackUniqueCandidates
|
|
||||||
: allRewards.filter((reward) => !excludeRewardId || reward.id !== excludeRewardId);
|
|
||||||
const reward = pool[Math.floor(Math.random() * pool.length)] || null;
|
|
||||||
if (!reward) throw new Error('No rewards configured');
|
|
||||||
|
|
||||||
button.rewardId = reward.id;
|
|
||||||
button.rewardName = reward.name || null;
|
|
||||||
button.rewardNumber = reward.number;
|
|
||||||
button.goal = reward.goal;
|
|
||||||
button.count = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRewardContext() {
|
|
||||||
return {
|
|
||||||
logger,
|
|
||||||
issueCommand,
|
|
||||||
listOnlineRovers: () => roverManager.getRoster(),
|
|
||||||
sendAlert,
|
|
||||||
publishEvent,
|
|
||||||
sendExternalTyping,
|
|
||||||
sendExternalMessage,
|
|
||||||
sendSystemMessage,
|
|
||||||
getMode,
|
|
||||||
setMode: (mode, source = 'buttonbox') =>
|
|
||||||
setMode(
|
|
||||||
mode,
|
|
||||||
{ data: { role: 'admin', user: { username: source } } },
|
|
||||||
{ force: true },
|
|
||||||
),
|
|
||||||
getAdminReasonText: () => getAdminReason()?.text || null,
|
|
||||||
setAdminReason: (text, source = 'buttonbox') => setAdminReason(text, { by: source }),
|
|
||||||
clearAdminReason: (source = 'buttonbox') => clearAdminReason({ by: source }),
|
|
||||||
rerollAssignments: () => assignmentService.rerollAssignments(),
|
|
||||||
getHomeAssistantEntities: () => {
|
|
||||||
const entities = getHomeAssistantState()?.entities;
|
|
||||||
return Array.isArray(entities) ? entities : [];
|
|
||||||
},
|
|
||||||
getHomeAssistantLightPolicy: () => getHomeAssistantState()?.lightPolicy || null,
|
|
||||||
setHomeAssistantEntityState,
|
|
||||||
setHomeAssistantLightsLockedOn: (next, options = {}) =>
|
|
||||||
setHomeAssistantLightsLockedOn(next, options),
|
|
||||||
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
|
|
||||||
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getButtonBoxState() {
|
|
||||||
loadState();
|
|
||||||
return clone({ buttons: state.buttons });
|
|
||||||
}
|
|
||||||
|
|
||||||
function publishUpdated() {
|
|
||||||
publishEvent({
|
|
||||||
source: 'buttonBox',
|
|
||||||
type: 'buttonBox.updated',
|
|
||||||
payload: { updatedAt: Date.now() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveEffect(effectId, payload = {}, options = {}) {
|
|
||||||
loadState();
|
|
||||||
state.effects = state.effects && typeof state.effects === 'object' ? state.effects : {};
|
|
||||||
state.effects[effectId] = payload;
|
|
||||||
writeState();
|
|
||||||
if (options.broadcast) {
|
|
||||||
publishUpdated();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearEffect(effectId, options = {}) {
|
|
||||||
loadState();
|
|
||||||
if (state.effects && typeof state.effects === 'object' && state.effects[effectId]) {
|
|
||||||
delete state.effects[effectId];
|
|
||||||
writeState();
|
|
||||||
if (options.broadcast) {
|
|
||||||
publishUpdated();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseButtonId(body) {
|
|
||||||
if (typeof body !== 'string') return null;
|
|
||||||
const value = Number.parseInt(body.trim(), 10);
|
|
||||||
if (Number.isFinite(value)) return value;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function denyIfNotLocal(req, res) {
|
|
||||||
const ip = normalizeIp(getRequestIp(req));
|
|
||||||
if (isLocalNetwork(ip)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
logger.warn('Rejected non-local button press request', { ip: ip || null });
|
|
||||||
res.status(403).json({ error: 'Button presses must originate from local network' });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRewardForButton(button) {
|
|
||||||
const reward = getRewardById(button.rewardId);
|
|
||||||
if (!reward) {
|
|
||||||
assignNewReward(button);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const completedCount = Number.isFinite(button.count) ? button.count : 0;
|
|
||||||
const completedGoal = Number.isFinite(button.goal) ? button.goal : 0;
|
|
||||||
const ctx = buildRewardContext();
|
|
||||||
try {
|
|
||||||
await reward.run(ctx);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Reward execution failed', { rewardId: reward.id, error: err.message });
|
|
||||||
}
|
|
||||||
io.emit('buttonBox:rewardRun', {
|
|
||||||
buttonId: button.id,
|
|
||||||
rewardId: reward.id,
|
|
||||||
rewardName: reward.name || reward.id,
|
|
||||||
count: completedCount,
|
|
||||||
goal: completedGoal,
|
|
||||||
ts: Date.now(),
|
|
||||||
});
|
|
||||||
button.lastRewardAt = Date.now();
|
|
||||||
button.count = 0;
|
|
||||||
assignNewReward(button, { excludeRewardId: reward.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyPress(buttonId) {
|
|
||||||
loadState();
|
|
||||||
const button = state.buttons.find((entry) => entry.id === buttonId);
|
|
||||||
if (!button) {
|
|
||||||
throw new Error('Unknown button');
|
|
||||||
}
|
|
||||||
|
|
||||||
button.count += 1;
|
|
||||||
button.lastIncrementAt = Date.now();
|
|
||||||
writeState();
|
|
||||||
|
|
||||||
io.emit('buttonBox:increment', {
|
|
||||||
buttonId,
|
|
||||||
count: button.count,
|
|
||||||
ts: button.lastIncrementAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (button.count >= button.goal) {
|
|
||||||
await runRewardForButton(button);
|
|
||||||
writeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
publishUpdated();
|
|
||||||
return clone(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recoverEffects() {
|
|
||||||
loadState();
|
|
||||||
const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {};
|
|
||||||
const ctx = buildRewardContext();
|
|
||||||
|
|
||||||
for (const [effectId, payload] of Object.entries(effects)) {
|
|
||||||
const reward = getRewardById(effectId);
|
|
||||||
if (!reward || typeof reward.recover !== 'function') {
|
|
||||||
clearEffect(effectId, { broadcast: false });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await reward.recover(ctx, payload);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Effect recovery failed', { effectId, error: err.message });
|
|
||||||
clearEffect(effectId, { broadcast: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
publishUpdated();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => {
|
|
||||||
if (denyIfNotLocal(req, res)) return;
|
|
||||||
const buttonId = parseButtonId(req.body);
|
|
||||||
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > BUTTON_COUNT) {
|
|
||||||
res.status(400).json({ error: 'button must be 1-4' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const button = await applyPress(buttonId);
|
|
||||||
res.json({ success: true, button });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message || 'Button processing failed' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
loadState();
|
const core = createButtonBoxCore({
|
||||||
recoverEffects().catch((err) => {
|
io,
|
||||||
|
logger,
|
||||||
|
getRewardById,
|
||||||
|
roverManager,
|
||||||
|
issueCommand,
|
||||||
|
sendAlert,
|
||||||
|
publishEvent,
|
||||||
|
sendExternalTyping,
|
||||||
|
sendExternalMessage,
|
||||||
|
sendSystemMessage,
|
||||||
|
getMode,
|
||||||
|
setMode,
|
||||||
|
getAdminReason,
|
||||||
|
setAdminReason,
|
||||||
|
clearAdminReason,
|
||||||
|
assignmentService,
|
||||||
|
getHomeAssistantState,
|
||||||
|
setHomeAssistantEntityState,
|
||||||
|
setHomeAssistantLightsLockedOn,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
|
||||||
|
registerButtonBoxRoute({
|
||||||
|
app,
|
||||||
|
logger,
|
||||||
|
buttonCount: BUTTON_COUNT,
|
||||||
|
getRequestIp,
|
||||||
|
normalizeIp,
|
||||||
|
isLocalNetwork,
|
||||||
|
applyPress: core.applyPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.loadState();
|
||||||
|
core.recoverEffects().catch((err) => {
|
||||||
logger.warn('Button box effect recovery failed', err.message);
|
logger.warn('Button box effect recovery failed', err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getButtonBoxState,
|
getButtonBoxState: store.getStateClone,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// button Box Service store
|
||||||
|
// Purpose: Owns persisted button-box state shape, normalization, and atomic file read/write operations.
|
||||||
|
// Scope: Keeps runtime behavior unchanged by isolating state persistence mechanics from reward/HTTP orchestration.
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
function createButtonBoxStore(deps) {
|
||||||
|
const {
|
||||||
|
logger,
|
||||||
|
getRewardById,
|
||||||
|
listRewards,
|
||||||
|
dataDir,
|
||||||
|
storePath,
|
||||||
|
buttonCount,
|
||||||
|
storeVersion,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
let state = null;
|
||||||
|
|
||||||
|
function createDefaultButton(id) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
count: 0,
|
||||||
|
rewardId: null,
|
||||||
|
rewardName: null,
|
||||||
|
rewardNumber: null,
|
||||||
|
goal: null,
|
||||||
|
lastIncrementAt: null,
|
||||||
|
lastRewardAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultState() {
|
||||||
|
return {
|
||||||
|
version: storeVersion,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
buttons: Array.from({ length: buttonCount }, (_, idx) => createDefaultButton(idx + 1)),
|
||||||
|
effects: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignNewReward(button, options = {}) {
|
||||||
|
const allRewards = listRewards();
|
||||||
|
if (!allRewards.length) throw new Error('No rewards configured');
|
||||||
|
const excludeRewardId = typeof options.excludeRewardId === 'string' ? options.excludeRewardId : null;
|
||||||
|
|
||||||
|
const usedByOtherButtons = new Set(
|
||||||
|
state.buttons
|
||||||
|
.filter((entry) => entry.id !== button.id)
|
||||||
|
.map((entry) => entry.rewardId)
|
||||||
|
.filter((rewardId) => typeof rewardId === 'string' && rewardId.length > 0),
|
||||||
|
);
|
||||||
|
const uniqueCandidates = allRewards.filter(
|
||||||
|
(reward) => !usedByOtherButtons.has(reward.id) && (!excludeRewardId || reward.id !== excludeRewardId),
|
||||||
|
);
|
||||||
|
const fallbackUniqueCandidates = allRewards.filter((reward) => !usedByOtherButtons.has(reward.id));
|
||||||
|
const pool = uniqueCandidates.length
|
||||||
|
? uniqueCandidates
|
||||||
|
: fallbackUniqueCandidates.length
|
||||||
|
? fallbackUniqueCandidates
|
||||||
|
: allRewards.filter((reward) => !excludeRewardId || reward.id !== excludeRewardId);
|
||||||
|
const reward = pool[Math.floor(Math.random() * pool.length)] || null;
|
||||||
|
if (!reward) throw new Error('No rewards configured');
|
||||||
|
|
||||||
|
button.rewardId = reward.id;
|
||||||
|
button.rewardName = reward.name || null;
|
||||||
|
button.rewardNumber = reward.number;
|
||||||
|
button.goal = reward.goal;
|
||||||
|
button.count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRewardAssignments() {
|
||||||
|
if (!state) return;
|
||||||
|
const seen = new Set();
|
||||||
|
state.buttons.forEach((button) => {
|
||||||
|
const reward = getRewardById(button.rewardId);
|
||||||
|
if (reward && !seen.has(reward.id)) {
|
||||||
|
seen.add(reward.id);
|
||||||
|
button.rewardName = reward.name || null;
|
||||||
|
button.rewardNumber = reward.number;
|
||||||
|
button.goal = reward.goal;
|
||||||
|
if (!Number.isFinite(button.count) || button.count < 0) {
|
||||||
|
button.count = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assignNewReward(button);
|
||||||
|
if (button.rewardId) {
|
||||||
|
seen.add(button.rewardId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLoaded(raw = {}) {
|
||||||
|
const base = createDefaultState();
|
||||||
|
const sourceButtons = Array.isArray(raw.buttons) ? raw.buttons : [];
|
||||||
|
const buttons = base.buttons.map((button, idx) => {
|
||||||
|
const loaded = sourceButtons[idx] || {};
|
||||||
|
return {
|
||||||
|
...button,
|
||||||
|
count: Number.isFinite(loaded.count) ? Math.max(0, Math.floor(loaded.count)) : 0,
|
||||||
|
rewardId: typeof loaded.rewardId === 'string' ? loaded.rewardId : null,
|
||||||
|
rewardName: typeof loaded.rewardName === 'string' ? loaded.rewardName : null,
|
||||||
|
rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null,
|
||||||
|
goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null,
|
||||||
|
lastIncrementAt: Number.isFinite(loaded.lastIncrementAt) ? loaded.lastIncrementAt : null,
|
||||||
|
lastRewardAt: Number.isFinite(loaded.lastRewardAt) ? loaded.lastRewardAt : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const effects = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
|
||||||
|
return {
|
||||||
|
version: storeVersion,
|
||||||
|
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
||||||
|
buttons,
|
||||||
|
effects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeState() {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
const next = {
|
||||||
|
...state,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const tempPath = `${storePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||||
|
fs.renameSync(tempPath, storePath);
|
||||||
|
state = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadState() {
|
||||||
|
if (state) return state;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(storePath, 'utf8'));
|
||||||
|
state = normalizeLoaded(raw);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
logger.warn('Failed to load button box store', err.message);
|
||||||
|
}
|
||||||
|
state = createDefaultState();
|
||||||
|
}
|
||||||
|
ensureRewardAssignments();
|
||||||
|
writeState();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
loadState();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateClone() {
|
||||||
|
return clone({ buttons: getState().buttons });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadState,
|
||||||
|
writeState,
|
||||||
|
getState,
|
||||||
|
getStateClone,
|
||||||
|
clone,
|
||||||
|
assignNewReward,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createButtonBoxStore,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user