mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
new new new
This commit is contained in:
@@ -40,6 +40,7 @@ homeAssistant:
|
||||
- id: "switch.dock_power"
|
||||
name: "Dock Power"
|
||||
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
||||
# For room-light policy, all configured entities are treated as room lights (including switches).
|
||||
buttons:
|
||||
# Legacy action entities only (for example sensor.<button>_action from Zigbee2MQTT).
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
@@ -57,6 +58,11 @@ homeAssistant:
|
||||
stateEquals: "hold"
|
||||
cooldownMs: 2000
|
||||
action: "modeAdmin"
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
# Room lights lock toggle
|
||||
stateEquals: "toggle"
|
||||
cooldownMs: 1000
|
||||
action: "lightsLockToggle"
|
||||
roomCameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
|
||||
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
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-dMtKgicC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BPze86Ff.css">
|
||||
<script type="module" crossorigin src="/assets/index-BVMt6HLR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkLRtqYZ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,9 +4,10 @@ const { createConnection, subscribeEntities, callService, Auth } = require('home
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('homeAssistantService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { getMode } = require('./modeManager');
|
||||
const { getMode, MODES, modeEvents } = require('./modeManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const { getActiveDrivers, turnEvents } = require('./turnService');
|
||||
|
||||
// home-assistant-js-websocket expects a global WebSocket in Node.
|
||||
if (!global.WebSocket) {
|
||||
@@ -22,11 +23,15 @@ const entityState = new Map(); // entityId -> normalized state
|
||||
const triggerConfig = []; // [{ runtimeKey, entityId, action, stateEquals, payload, cooldownMs, allowedModes }]
|
||||
const triggerRuntime = new Map(); // triggerId -> { lastFiredAt, lastState, lastChanged, lastUpdated }
|
||||
const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
|
||||
const LIGHT_IDLE_OFF_MS = 2 * 60 * 1000;
|
||||
|
||||
let connection = null;
|
||||
let unsubscribeEntities = null;
|
||||
let reconnectTimer = null;
|
||||
let connected = false;
|
||||
let lightsLockedOn = false;
|
||||
let lightsIdleOffTimer = null;
|
||||
let lightsIdleOffDeadline = null;
|
||||
|
||||
const enabled = Boolean(haConfig?.url && haConfig?.token);
|
||||
|
||||
@@ -182,6 +187,68 @@ function emitStatus() {
|
||||
events.emit('status', getState());
|
||||
}
|
||||
|
||||
function getControllableEntityIds() {
|
||||
return Array.from(entityConfig.values()).map((meta) => String(meta.id));
|
||||
}
|
||||
|
||||
function getActiveDriverCount() {
|
||||
const active = getActiveDrivers();
|
||||
if (!active || typeof active !== 'object') return 0;
|
||||
return Object.keys(active).length;
|
||||
}
|
||||
|
||||
function hasActiveDrivers() {
|
||||
return getActiveDriverCount() > 0;
|
||||
}
|
||||
|
||||
function clearLightsIdleOffTimer() {
|
||||
if (lightsIdleOffTimer) {
|
||||
clearTimeout(lightsIdleOffTimer);
|
||||
lightsIdleOffTimer = null;
|
||||
}
|
||||
if (lightsIdleOffDeadline != null) {
|
||||
lightsIdleOffDeadline = null;
|
||||
emitUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLightsIdleOffTimer() {
|
||||
if (!enabled) return;
|
||||
if (getControllableEntityIds().length === 0) return;
|
||||
if (lightsIdleOffTimer || lightsLockedOn || hasActiveDrivers()) {
|
||||
return;
|
||||
}
|
||||
lightsIdleOffDeadline = Date.now() + LIGHT_IDLE_OFF_MS;
|
||||
lightsIdleOffTimer = setTimeout(async () => {
|
||||
lightsIdleOffTimer = null;
|
||||
lightsIdleOffDeadline = null;
|
||||
try {
|
||||
await setAllControllableEntitiesState('off');
|
||||
logger.info('Auto-turned off room lights due to no active drivers', {
|
||||
idleMs: LIGHT_IDLE_OFF_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed auto light-off after idle', err.message);
|
||||
} finally {
|
||||
emitUpdate();
|
||||
evaluateLightAutomation();
|
||||
}
|
||||
}, LIGHT_IDLE_OFF_MS);
|
||||
emitUpdate();
|
||||
}
|
||||
|
||||
function evaluateLightAutomation() {
|
||||
if (lightsLockedOn) {
|
||||
clearLightsIdleOffTimer();
|
||||
return;
|
||||
}
|
||||
if (hasActiveDrivers()) {
|
||||
clearLightsIdleOffTimer();
|
||||
return;
|
||||
}
|
||||
scheduleLightsIdleOffTimer();
|
||||
}
|
||||
|
||||
function handleEntitySnapshot(snapshot = {}) {
|
||||
let changed = false;
|
||||
entityConfig.forEach((meta, id) => {
|
||||
@@ -353,6 +420,20 @@ async function setEntityState(entityId, desiredState) {
|
||||
logger.info('Issued Home Assistant command', { entityId, domain, service });
|
||||
}
|
||||
|
||||
async function setAllControllableEntitiesState(desiredState) {
|
||||
const ids = getControllableEntityIds();
|
||||
if (!ids.length) return;
|
||||
const results = await Promise.allSettled(ids.map((id) => setEntityState(id, desiredState)));
|
||||
const failures = results.filter((result) => result.status === 'rejected');
|
||||
if (failures.length) {
|
||||
logger.warn('Some Home Assistant entity state updates failed', {
|
||||
desiredState,
|
||||
total: ids.length,
|
||||
failed: failures.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEntity(entityId) {
|
||||
const current = entityState.get(entityId);
|
||||
const nextState = current?.state === 'on' ? 'off' : 'on';
|
||||
@@ -382,16 +463,86 @@ async function setLightColor(entityId, rgbColor) {
|
||||
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
|
||||
}
|
||||
|
||||
function isLightControlLocked() {
|
||||
return lightsLockedOn;
|
||||
}
|
||||
|
||||
function getLightPolicyState() {
|
||||
return {
|
||||
lockedOn: lightsLockedOn,
|
||||
idleOffMs: LIGHT_IDLE_OFF_MS,
|
||||
idleOffAt: lightsIdleOffDeadline,
|
||||
activeDrivers: getActiveDriverCount(),
|
||||
};
|
||||
}
|
||||
|
||||
async function setLightsLockedOn(nextValue, options = {}) {
|
||||
const next = Boolean(nextValue);
|
||||
const forceApply = Boolean(options.forceApply);
|
||||
const changed = lightsLockedOn !== next;
|
||||
lightsLockedOn = next;
|
||||
if (lightsLockedOn) {
|
||||
clearLightsIdleOffTimer();
|
||||
if (changed || forceApply) {
|
||||
if (enabled) {
|
||||
await setAllControllableEntitiesState('on');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
evaluateLightAutomation();
|
||||
}
|
||||
if (changed) {
|
||||
logger.info('Room lights lock state changed', {
|
||||
lockedOn: lightsLockedOn,
|
||||
source: options.source || 'unknown',
|
||||
});
|
||||
}
|
||||
emitUpdate();
|
||||
return lightsLockedOn;
|
||||
}
|
||||
|
||||
async function toggleLightsLockedOn(options = {}) {
|
||||
return setLightsLockedOn(!lightsLockedOn, options);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
const entities = Array.from(entityConfig.values()).map(
|
||||
(meta) => entityState.get(meta.id) || buildState(meta, null),
|
||||
);
|
||||
return { enabled, connected, entities };
|
||||
return {
|
||||
enabled,
|
||||
connected,
|
||||
entities,
|
||||
lightPolicy: getLightPolicyState(),
|
||||
};
|
||||
}
|
||||
|
||||
loadEntityConfig();
|
||||
loadTriggerConfig();
|
||||
connect();
|
||||
evaluateLightAutomation();
|
||||
|
||||
turnEvents.on('activeDriver', () => {
|
||||
evaluateLightAutomation();
|
||||
});
|
||||
|
||||
turnEvents.on('queue', () => {
|
||||
evaluateLightAutomation();
|
||||
});
|
||||
|
||||
modeEvents.on('change', (mode) => {
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
if (lightsLockedOn) {
|
||||
setLightsLockedOn(false, { source: 'modeGateReset' }).catch((err) => {
|
||||
logger.warn('Failed to disable lights lock on mode change', err.message);
|
||||
});
|
||||
} else {
|
||||
evaluateLightAutomation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
evaluateLightAutomation();
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
|
||||
@@ -402,7 +553,9 @@ io.on('connection', (socket) => {
|
||||
) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
|
||||
if (isLightControlLocked()) {
|
||||
return cb({ error: 'Room controls are locked on' });
|
||||
}
|
||||
try {
|
||||
if (!entityId) throw new Error('entityId required');
|
||||
await toggleEntity(entityId);
|
||||
@@ -420,6 +573,9 @@ io.on('connection', (socket) => {
|
||||
) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
if (isLightControlLocked()) {
|
||||
return cb({ error: 'Room controls are locked on' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!entityId) throw new Error('entityId required');
|
||||
@@ -438,6 +594,9 @@ io.on('connection', (socket) => {
|
||||
) {
|
||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||
}
|
||||
if (isLightControlLocked()) {
|
||||
return cb({ error: 'Room controls are locked on' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!entityId) throw new Error('entityId required');
|
||||
@@ -451,8 +610,12 @@ io.on('connection', (socket) => {
|
||||
|
||||
module.exports = {
|
||||
getState,
|
||||
getLightPolicyState,
|
||||
isLightControlLocked,
|
||||
toggleEntity,
|
||||
setEntityState,
|
||||
setLightColor,
|
||||
setLightsLockedOn,
|
||||
toggleLightsLockedOn,
|
||||
homeAssistantEvents: events,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ const { subscribe, publishEvent } = require('./eventBus');
|
||||
const { getMode, MODES, setMode } = require('./modeManager');
|
||||
const { issueCommand } = require('./commandService');
|
||||
const roverManager = require('./roverManager');
|
||||
const { toggleLightsLockedOn } = require('./homeAssistantService');
|
||||
const { getRoomCameras } = require('./roomCameraService');
|
||||
const { getRoomCameraState } = require('./roomCameraSnapshotService');
|
||||
|
||||
@@ -11,14 +12,17 @@ const HA_BUTTON_EVENT_TYPE = 'ha.button.action';
|
||||
const HUMAN_ALERT_ACTION = 'humanAlert';
|
||||
const MODE_TURNS_ACTION = 'modeTurns';
|
||||
const MODE_ADMIN_ACTION = 'modeAdmin';
|
||||
const LIGHTS_LOCK_TOGGLE_ACTION = 'lightsLockToggle';
|
||||
const HUMAN_ALERT_MESSAGE = 'Human alert button pressed.';
|
||||
const MODE_TURNS_TTS = 'Server mode is now turns.';
|
||||
const MODE_ADMIN_TTS = 'Server mode is now admin.';
|
||||
const LIGHTS_LOCKED_TTS = 'Room lights are now locked on.';
|
||||
const LIGHTS_UNLOCKED_TTS = 'Room lights are now unlocked.';
|
||||
const TILE_WIDTH = 480;
|
||||
const TILE_HEIGHT = 270;
|
||||
|
||||
logger.info('HA button actions enabled', {
|
||||
actions: [HUMAN_ALERT_ACTION, MODE_TURNS_ACTION, MODE_ADMIN_ACTION],
|
||||
actions: [HUMAN_ALERT_ACTION, MODE_TURNS_ACTION, MODE_ADMIN_ACTION, LIGHTS_LOCK_TOGGLE_ACTION],
|
||||
});
|
||||
|
||||
function isModeAllowed() {
|
||||
@@ -122,6 +126,14 @@ async function handleTrigger(event = {}) {
|
||||
sendTtsToNonPrivateRovers(MODE_ADMIN_TTS);
|
||||
return;
|
||||
}
|
||||
if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
|
||||
const lockedOn = await toggleLightsLockedOn({
|
||||
source: 'ha-button:lightsLockToggle',
|
||||
forceApply: true,
|
||||
});
|
||||
sendTtsToNonPrivateRovers(lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS);
|
||||
return;
|
||||
}
|
||||
if (action !== HUMAN_ALERT_ACTION) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,19 +84,21 @@ function getEntityHue(entity) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function EntityRow({ entity, connected, onToggle, onSetColor }) {
|
||||
function EntityRow({ entity, connected, controlsLocked, onToggle, onSetColor }) {
|
||||
const unavailable = entity.state === 'unavailable' || !entity.available;
|
||||
const isOn = entity.state === 'on';
|
||||
const supportsColor = entity.type === 'light' && entity.supportsColor;
|
||||
const statusTone = unavailable ? 'warn' : isOn ? 'success' : 'muted';
|
||||
const statusLabel = unavailable ? 'Unavailable' : isOn ? 'On' : 'Off';
|
||||
const disableToggle = !connected || unavailable;
|
||||
const disableToggle = controlsLocked || !connected || unavailable;
|
||||
const disableColor = disableToggle || !supportsColor;
|
||||
const [hue, setHue] = useState(() => getEntityHue(entity));
|
||||
const hueRef = useRef(hue);
|
||||
const draggingRef = useRef(false);
|
||||
const toneStyles = unavailable
|
||||
? 'border-slate-800 bg-slate-900 text-slate-400 cursor-not-allowed'
|
||||
: controlsLocked
|
||||
? 'border-slate-800 bg-slate-900 text-slate-300 cursor-not-allowed'
|
||||
: isOn
|
||||
? 'border-emerald-700 bg-emerald-900/80 text-emerald-50 hover:bg-emerald-800'
|
||||
: 'border-rose-800 bg-rose-900/80 text-rose-50 hover:bg-rose-800';
|
||||
@@ -210,6 +212,8 @@ export default function HomeAssistantControls() {
|
||||
const { session, homeAssistantToggle, homeAssistantSetLightColor } = useSession();
|
||||
const ha = session?.homeAssistant;
|
||||
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
|
||||
const lightPolicy = ha?.lightPolicy || null;
|
||||
const controlsLocked = Boolean(lightPolicy?.lockedOn);
|
||||
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
|
||||
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
|
||||
|
||||
@@ -239,6 +243,7 @@ export default function HomeAssistantControls() {
|
||||
<div className="flex items-center gap-0.5">
|
||||
<p>Room Controls</p>
|
||||
<span className="text-xs text-slate-500">{entities.length}</span>
|
||||
{controlsLocked ? <StatusBadge label="Locked On" tone="warn" /> : null}
|
||||
<div className="flex items-center gap-0.5 text-xs text-slate-300 background-black">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span>On</span>
|
||||
@@ -252,12 +257,18 @@ export default function HomeAssistantControls() {
|
||||
</div>
|
||||
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
||||
</header>
|
||||
{controlsLocked ? (
|
||||
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
||||
Lights are locked on. Room controls are disabled.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{entities.map((entity) => (
|
||||
<EntityRow
|
||||
key={entity.id}
|
||||
entity={entity}
|
||||
connected={connected}
|
||||
controlsLocked={controlsLocked}
|
||||
onToggle={homeAssistantToggle}
|
||||
onSetColor={homeAssistantSetLightColor}
|
||||
/>
|
||||
|
||||
@@ -317,6 +317,7 @@ export default function KeyboardInputManager() {
|
||||
(targetState) => {
|
||||
const ha = session?.homeAssistant;
|
||||
if (!ha?.enabled || !ha?.connected) return;
|
||||
if (ha?.lightPolicy?.lockedOn) return;
|
||||
const entities = ha.entities || [];
|
||||
const eligible = entities.filter(
|
||||
(ent) =>
|
||||
|
||||
Reference in New Issue
Block a user