home assistant entities yay yay

This commit is contained in:
legop3
2026-09-16 12:22:41 -04:00
parent b48348d89e
commit f3e3b6a803
26 changed files with 646 additions and 5 deletions
@@ -0,0 +1,8 @@
export default function ButtonControl({ entity, disabled, onChange }) {
// HA button states are timestamps. A press is an action, never a toggle.
return <>
<button type="button" aria-label={`Press ${entity.name}`} disabled={disabled}
onClick={() => onChange('press')} className="button-dark w-full px-1 py-0.5 text-xs disabled:opacity-50">Press</button>
</>;
}
@@ -0,0 +1,27 @@
import useDraftControl from '../useDraftControl';
export default function NumberControl({ entity, disabled, onChange }) {
const limitsKnown = entity.min !== null && entity.max !== null;
const control = useDraftControl(onChange, disabled || !limitsKnown);
const value = control.draft ?? (entity.available ? entity.state : '');
const blocked = disabled || !limitsKnown;
// Sliders commit on release (including keyboard adjustment), not for every
// intermediate position. Number typing uses the same local draft and debounce.
return <>
<div className="flex min-w-0 items-center gap-0.5">
{limitsKnown ? <input type="range" aria-label={`${entity.name} slider`} min={entity.min} max={entity.max} step={entity.step || 'any'}
value={value || entity.min} disabled={blocked} className="min-w-0 flex-1 accent-emerald-500 disabled:opacity-50"
onChange={(event) => control.edit(event.target.value, false)}
onPointerUp={(event) => control.commit(event.currentTarget.value)}
onKeyUp={(event) => {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) control.commit(event.currentTarget.value);
}} /> : null}
<input type="number" aria-label={entity.name} min={entity.min ?? undefined} max={entity.max ?? undefined} step={entity.step || 'any'}
value={value} disabled={blocked} onChange={(event) => control.edit(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }}
className="w-16 min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
{entity.unit ? <span className="text-[0.65rem] text-slate-400">{entity.unit}</span> : null}
</div>
{!limitsKnown ? <span className="text-xs text-amber-200">Waiting for number limits</span> : null}
</>;
}
@@ -0,0 +1,6 @@
// Preserve raw sensor text and units; a sensor's non-on value is never "off".
export default function ReadOnlyControl({ entity }) {
return <span className="break-words text-xs text-slate-200">
{entity.available ? (entity.password ? '••••••' : `${entity.state}${entity.unit ? ` ${entity.unit}` : ''}`) : 'Unavailable'}
</span>;
}
@@ -0,0 +1,13 @@
export default function SelectControl({ entity, disabled, onChange }) {
// Keep the actual reported value visible even if HA changes its option list;
// only current options are selectable or accepted by the server.
return <>
<select aria-label={entity.name} value={entity.state} disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50">
{!entity.options.includes(entity.state) ? <option value={entity.state} disabled>{entity.state}</option> : null}
{entity.options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</>;
}
@@ -0,0 +1,19 @@
import { useRef } from 'react';
import useDraftControl from '../useDraftControl';
export default function TextControl({ entity, disabled, onChange }) {
const control = useDraftControl(onChange, disabled);
const composing = useRef(false);
// IME composition may pause mid-word, so it must finish before automatic
// sending starts. Enter remains an immediate action for ordinary typing.
return <>
<input type={entity.password ? 'password' : 'text'} aria-label={entity.name}
value={control.draft ?? (entity.available ? entity.state : '')} disabled={disabled}
minLength={entity.min ?? undefined} maxLength={entity.max ?? 255}
onChange={(event) => control.edit(event.target.value, !composing.current)}
onCompositionStart={() => { composing.current = true; control.cancel(); }}
onCompositionEnd={(event) => { composing.current = false; control.edit(event.currentTarget.value); }}
onKeyDown={(event) => { if (event.key === 'Enter' && !composing.current) control.commit(event.currentTarget.value); }}
className="w-full min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
</>;
}
@@ -0,0 +1,13 @@
export default function ToggleControl({ entity, disabled, onChange }) {
const on = entity.state === 'on';
// The label reflects reported state, rather than claiming success before HA
// publishes it. The entire compact button remains an accessible click target.
return <>
<button type="button" aria-label={`Toggle ${entity.name}`} aria-pressed={on}
disabled={disabled} onClick={() => onChange(on ? 'off' : 'on')}
className={`w-full rounded border px-1 py-0.5 text-xs font-semibold disabled:opacity-50 ${on ? 'border-emerald-700/70 bg-emerald-900 text-white' : 'border-neutral-700 bg-neutral-950 text-slate-300'}`}>
{on ? 'On' : 'Off'}
</button>
</>;
}
@@ -0,0 +1,58 @@
import { useCallback } from 'react';
import * as FaIcons from 'react-icons/fa';
import { FaCube, FaLock } from 'react-icons/fa';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext';
import CardFrame from '../CardFrame';
// Each control keeps its own JSX file; this panel only selects its renderer.
import ReadOnlyControl from './controls/ReadOnlyControl';
import ToggleControl from './controls/ToggleControl';
import NumberControl from './controls/NumberControl';
import TextControl from './controls/TextControl';
import SelectControl from './controls/SelectControl';
import ButtonControl from './controls/ButtonControl';
const controls = { readOnly: ReadOnlyControl, toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl, button: ButtonControl };
function ActivityTile({ entity, connected, allowed, admin }) {
const { homeAssistantActivityAct } = useSessionActions();
const onChange = useCallback((value) => homeAssistantActivityAct(entity.id, value), [homeAssistantActivityAct, entity.id]);
// Resolve precisely the same Font Awesome names accepted by social links.
// Unknown names remain usable with a neutral fallback rather than an error.
const candidate = FaIcons[entity.icon?.trim()];
const Icon = typeof candidate === 'function' ? candidate : FaCube;
const Control = controls[entity.type] || ReadOnlyControl;
const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin);
return <div className={`flex min-w-0 flex-col gap-0.5 rounded border px-0.5 py-0.5 ${entity.locked ? 'border-amber-700/60 bg-amber-950/70' : 'border-neutral-700 bg-neutral-950'}`}>
<div className="-mx-0.5 -mt-0.5 flex min-h-5 min-w-0 items-center gap-0.5 rounded-t bg-black/45 px-0.5 py-0.5 text-white">
<Icon className="shrink-0 text-xs" aria-hidden="true" />
<span title={entity.name} className="min-w-0 flex-1 truncate text-[0.78rem] font-semibold leading-none">{entity.name}</span>
{entity.locked ? <span title={admin ? 'Locked for users; admins can still control' : 'Locked'} className="flex items-center gap-0.5 text-[0.65rem] text-amber-200"><FaLock aria-hidden="true" />Locked</span> : null}
</div>
{!entity.available && entity.type !== 'readOnly' ? <span className="text-xs text-amber-200">Unavailable</span> : null}
<Control entity={entity} disabled={disabled} onChange={onChange} />
</div>;
}
export default function HomeAssistantActivitiesPanel() {
const state = useSessionSelector((session) => session.session?.homeAssistantActivities);
const socketConnected = useSessionSelector((session) => session.connected);
const role = useSessionSelector((session) => session.session?.role);
const mode = useSessionSelector((session) => session.session?.mode);
const admin = role === 'admin' || role === 'lockdown';
const allowed = ['user', 'admin', 'lockdown'].includes(role) && (mode !== 'admin' || admin) && (mode !== 'lockdown' || role === 'lockdown');
if (!state?.enabled || !state.items.length) return null;
// Session data survives a browser disconnect. Disable immediately so local
// debounce timers are cancelled even when HA itself remains connected.
const connected = state.connected && socketConnected;
// Reuse the room-control auto-fit grid and compact tile rhythm, while keeping
// all data and commands in the Activities namespace on both device layouts.
return <CardFrame title="Home Assistant" bodyClassName="space-y-0.5 text-sm"
actions={<span className={`rounded px-1 py-0.5 text-xs font-semibold leading-none ${connected ? 'bg-emerald-900 text-emerald-100' : 'bg-amber-900 text-amber-100'}`}>{connected ? 'Connected' : 'Offline'}</span>}>
{!connected ? <p className="px-0.5 text-xs text-amber-200">{socketConnected ? 'Home Assistant is offline.' : 'Server disconnected.'} Values may be out of date.</p> : null}
{!allowed ? <p className="px-0.5 text-xs text-slate-400">Controls are read-only with your current access.</p> : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(9rem,100%),1fr))] gap-0.5">
{state.items.map((entity) => <ActivityTile key={entity.id} entity={entity} connected={connected} allowed={allowed} admin={admin} />)}
</div>
</CardFrame>;
}
@@ -0,0 +1,32 @@
// Number/text drafts are local until the short pause expires. HA broadcasts
// cannot erase unfinished typing. After sending, the reported state owns the UI.
import { useCallback, useEffect, useRef, useState } from 'react';
export default function useDraftControl(onChange, disabled) {
const [draft, setDraft] = useState(null);
const timer = useRef(null);
const cancel = useCallback(() => {
clearTimeout(timer.current);
timer.current = null;
}, []);
// A lock, disconnect, or unmount cancels queued edits. Unlocking must never
// replay a change that was typed before permission was removed.
useEffect(() => cancel, [cancel, disabled]);
const commit = useCallback((value) => {
cancel();
if (disabled) return;
// Sending ends the local edit, not a request/response transaction. The
// next HA broadcast updates this input just like any external change.
onChange(value);
setDraft(null);
}, [onChange, disabled, cancel]);
const edit = (value, debounce = true) => {
cancel();
setDraft(value);
if (debounce && !disabled) timer.current = setTimeout(() => commit(value), 650);
};
return { draft, edit, commit, cancel };
}
+7 -1
View File
@@ -351,6 +351,12 @@ export function SessionProvider({ children }) {
subscribeAll: () => emitWithAck('session:subscribeAll'),
lockRover: (roverId, locked) => emitWithAck('session:lockRover', { roverId, locked }),
setMode: (mode) => emitWithAck('setMode', { mode }),
// Activity controls are driven by HA broadcasts, not acknowledgements.
// Skip disconnected edits instead of buffering and replaying stale
// values when the browser reconnects.
homeAssistantActivityAct: (id, value) => {
if (socket.connected) socket.emit('homeAssistantActivities:act', { id, value });
},
homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }),
homeAssistantSetState: (entityId, state) =>
emitWithAck('homeAssistant:setState', { entityId, state }),
@@ -428,7 +434,7 @@ export function SessionProvider({ children }) {
clearLatestReplay: () =>
setState((prev) => (prev.latestReplay ? { ...prev, latestReplay: null } : prev)),
}),
[emitWithAck, setState],
[emitWithAck, setState, socket],
);
const store = useMemo(
@@ -1,6 +1,7 @@
// Driver Activities Tab
// Purpose: Owns the shared desktop/mobile ordering of activity cards.
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import HomeAssistantActivitiesPanel from '../../../../../components/HomeAssistantActivitiesPanel/index.jsx';
import NeatoCard from '../../../../../components/NeatoCard/index.jsx';
import LiftCard from '../../../../../components/LiftCard/index.jsx';
import BalanceBoardPanel from '../../../../../components/BalanceBoardPanel/index.jsx';
@@ -17,12 +18,12 @@ export default function ActivitiesTab() {
<div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard />
<LiftCard />
<HomeAssistantActivitiesPanel />
<BalanceBoardPanel />
<BarcodeGamesPanel />
<OdometerPanel />
<ButtonBoxPanel />
<KinectPanel />
{/* Fleet reports retains its existing terminal position and self-gate. */}
<FleetReportsCard />
</div>
</TabPanel>