mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
slorp
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
// Compose one HA action from primitive controls. Required companion fields are
|
||||
// sent together, while unrelated optional properties are never overwritten.
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
import ColorControl from './controls/ColorControl';
|
||||
import DateControl from './controls/DateControl';
|
||||
import TimeControl from './controls/TimeControl';
|
||||
import DateTimeControl from './controls/DateTimeControl';
|
||||
|
||||
const controls = { toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl,
|
||||
button: ButtonControl, color: ColorControl, date: DateControl, time: TimeControl, datetime: DateTimeControl };
|
||||
|
||||
export default function ActionControls({ action, entityName, disabled, onAction, hideButton = false }) {
|
||||
const [draft, setDraft] = useState({});
|
||||
const fields = action.fields.filter((field) => !field.hidden);
|
||||
const run = (field, value) => {
|
||||
if (disabled) return;
|
||||
const edits = field ? { ...draft, [field.key]: value } : { ...draft };
|
||||
const values = { ...edits };
|
||||
// Most commands are independent writes. A thermostat range or another
|
||||
// multi-input action also needs the remaining required values, using live
|
||||
// state/defaults unless the user has already supplied a local edit.
|
||||
for (const required of action.fields.filter((candidate) => candidate.required)) {
|
||||
if (values[required.key] === undefined) values[required.key] = required.state ?? required.default;
|
||||
}
|
||||
const missing = action.fields.some((candidate) => candidate.required
|
||||
&& (values[candidate.key] === null || values[candidate.key] === undefined));
|
||||
if (missing) {
|
||||
// Retain only explicit edits. Remembering inferred companion values here
|
||||
// would overwrite newer HA state when the last required field is entered.
|
||||
setDraft(edits);
|
||||
return;
|
||||
}
|
||||
onAction(action.id, values);
|
||||
setDraft({});
|
||||
};
|
||||
const renderField = (field) => {
|
||||
const Control = controls[field.type];
|
||||
if (!Control) return null;
|
||||
const descriptor = { ...field, name: `${entityName}: ${field.name}`,
|
||||
state: Object.hasOwn(draft, field.key) ? draft[field.key] : field.state ?? field.default, available: true };
|
||||
return <div key={field.key} className="min-w-0 space-y-0.5">
|
||||
<span className="text-[0.68rem] text-slate-300" title={action.name}>{field.name}</span>
|
||||
<Control entity={descriptor} disabled={disabled} onChange={(value) => run(field, value)} />
|
||||
</div>;
|
||||
};
|
||||
const primary = fields.filter((field) => !field.advanced || field.required);
|
||||
const advanced = fields.filter((field) => field.advanced && !field.required);
|
||||
// HA sometimes marks setter inputs optional (for alternative payloads).
|
||||
// A parameterless Set/Select button would do nothing or fail, not represent
|
||||
// another useful control. Ordinary actions can still run with optional args.
|
||||
const setter = /^(set_|select_)|_set$/.test(action.service);
|
||||
const canRunWithoutFields = !action.fields.some((field) => field.required) && (!fields.length || !setter);
|
||||
if (!fields.length && hideButton) return null;
|
||||
return <div className={`min-w-0 space-y-0.5 ${fields.length ? 'w-full' : ''}`}>
|
||||
{/* Parameterless and optional-only actions remain usable as ordinary
|
||||
buttons; changing a field invokes that same named action directly. */}
|
||||
{!hideButton && canRunWithoutFields ? <ButtonControl entity={{ name: `${entityName}: ${action.name}`, label: action.name }} disabled={disabled} onChange={() => run()} /> : null}
|
||||
{primary.map(renderField)}
|
||||
{advanced.length ? <details className="text-[0.68rem] text-slate-400"><summary className="cursor-pointer">More {action.name.toLowerCase()} options</summary><div className="space-y-0.5">{advanced.map(renderField)}</div></details> : null}
|
||||
{Object.keys(draft).length ? <p className="text-[0.65rem] text-slate-400">Complete the required fields to apply.</p> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
export default function ButtonControl({ entity, disabled, onChange }) {
|
||||
// HA button states are timestamps. A press is an action, never a toggle.
|
||||
// Discovered actions and constant-valued inputs execute immediately; their
|
||||
// result is reflected only by subsequent HA state updates.
|
||||
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>
|
||||
<button type="button" aria-label={entity.name} disabled={disabled}
|
||||
onClick={() => onChange(entity.constant ?? true)} className="button-dark w-full px-1 py-0.5 text-xs disabled:opacity-50">{entity.label || entity.name || 'Press'}</button>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// HA supplies an RGB triple while the native browser color picker uses hex.
|
||||
// Convert only at this UI boundary; no optimistic entity state is stored here.
|
||||
export default function ColorControl({ entity, disabled, onChange }) {
|
||||
const rgb = Array.isArray(entity.state) ? entity.state : [255, 255, 255];
|
||||
const hex = `#${rgb.slice(0, 3).map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, '0')).join('')}`;
|
||||
return <input type="color" aria-label={entity.name} value={hex} disabled={disabled}
|
||||
onChange={(event) => onChange([1, 3, 5].map((offset) => Number.parseInt(event.target.value.slice(offset, offset + 2), 16)))}
|
||||
className="h-6 w-full cursor-pointer rounded border border-neutral-700 bg-transparent disabled:opacity-50" />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Native pickers keep date/time editing compact; the usual draft pause avoids
|
||||
// sending partially edited values and HA remains responsible for validation.
|
||||
import useDraftControl from '../useDraftControl';
|
||||
|
||||
export default function DateControl({ entity, disabled, onChange }) {
|
||||
const control = useDraftControl(onChange, disabled);
|
||||
return <input type="date" aria-label={entity.name} disabled={disabled}
|
||||
value={control.draft ?? entity.state ?? ''}
|
||||
onChange={(event) => control.edit(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') 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,12 @@
|
||||
// Native pickers keep date/time editing compact; the usual draft pause avoids
|
||||
// sending partially edited values and HA remains responsible for validation.
|
||||
import useDraftControl from '../useDraftControl';
|
||||
|
||||
export default function DateTimeControl({ entity, disabled, onChange }) {
|
||||
const control = useDraftControl(onChange, disabled);
|
||||
return <input type="datetime-local" aria-label={entity.name} disabled={disabled}
|
||||
value={control.draft ?? entity.state ?? ''}
|
||||
onChange={(event) => control.edit(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') 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" />;
|
||||
}
|
||||
@@ -2,15 +2,15 @@ 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;
|
||||
const control = useDraftControl(onChange, disabled);
|
||||
const value = control.draft ?? entity.state ?? '';
|
||||
const blocked = disabled;
|
||||
// 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"
|
||||
value={value || entity.min} disabled={blocked} className="min-w-0 flex-1 accent-blue-400 disabled:opacity-50"
|
||||
onChange={(event) => control.edit(event.target.value, false)}
|
||||
onPointerUp={(event) => control.commit(event.currentTarget.value)}
|
||||
onKeyUp={(event) => {
|
||||
@@ -22,6 +22,5 @@ export default function NumberControl({ entity, disabled, onChange }) {
|
||||
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}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ 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)}
|
||||
<select aria-label={entity.name} value={entity.options.findIndex((option) => option.value === entity.state)} disabled={disabled}
|
||||
onChange={(event) => onChange(entity.options[Number(event.target.value)].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>)}
|
||||
{!entity.options.includes(entity.state) ? <option value={entity.options.findIndex((option) => option.value === entity.state)} disabled>{entity.state}</option> : null}
|
||||
{entity.options.map((option, index) => <option key={index} value={index}>{option.label}</option>)}
|
||||
</select>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ export default function TextControl({ entity, disabled, onChange }) {
|
||||
// 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}
|
||||
value={control.draft ?? entity.state ?? ''} disabled={disabled}
|
||||
minLength={entity.min ?? undefined} maxLength={entity.max ?? undefined}
|
||||
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); }}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Native pickers keep date/time editing compact; the usual draft pause avoids
|
||||
// sending partially edited values and HA remains responsible for validation.
|
||||
import useDraftControl from '../useDraftControl';
|
||||
|
||||
export default function TimeControl({ entity, disabled, onChange }) {
|
||||
const control = useDraftControl(onChange, disabled);
|
||||
return <input type="time" aria-label={entity.name} disabled={disabled}
|
||||
value={control.draft ?? entity.state ?? ''}
|
||||
onChange={(event) => control.edit(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') 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" />;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
|
||||
export default function ToggleControl({ entity, disabled, onChange }) {
|
||||
const on = entity.state === 'on';
|
||||
const on = entity.state === true || 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')}
|
||||
disabled={disabled} onClick={() => onChange(!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>
|
||||
|
||||
@@ -3,34 +3,49 @@ 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';
|
||||
import ActionControls from './ActionControls';
|
||||
|
||||
const controls = { readOnly: ReadOnlyControl, toggle: ToggleControl, number: NumberControl, text: TextControl, select: SelectControl, button: ButtonControl };
|
||||
|
||||
|
||||
function ActivityTile({ entity, connected, allowed, admin }) {
|
||||
function ActivityTile({ entity, connected, controlsReady, 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 onAction = useCallback((action, values) => homeAssistantActivityAct(entity.id, action, values), [homeAssistantActivityAct, entity.id]);
|
||||
// Icon/color inputs follow social-link conventions. A translucent fill keeps
|
||||
// text legible even with bright colors and preserves the surrounding theme.
|
||||
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">
|
||||
const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6';
|
||||
const disabled = !connected || !controlsReady || !entity.available || !allowed || (entity.locked && !admin);
|
||||
const domain = entity.id.split('.')[0];
|
||||
const turnOn = entity.actions.find((action) => action.domain === domain && action.service === 'turn_on' && !action.fields.some((field) => field.required));
|
||||
const turnOff = entity.actions.find((action) => action.domain === domain && action.service === 'turn_off' && !action.fields.some((field) => field.required));
|
||||
const power = turnOn && turnOff;
|
||||
// Pair the universal on/off actions into one control. Other capabilities
|
||||
// continue to be rendered from metadata, including optional turn_on inputs.
|
||||
const actions = entity.actions.filter((action) => !(power && action.service === 'toggle'));
|
||||
return <div className="flex min-w-0 flex-col gap-0.5 rounded border px-0.5 py-0.5"
|
||||
style={{ borderColor: entity.locked ? '#b45309' : `${color}aa`, backgroundColor: `${color}33` }}>
|
||||
<div className="-mx-0.5 -mt-0.5 flex min-h-5 min-w-0 items-center gap-0.5 rounded-t px-0.5 py-0.5 text-white" style={{ backgroundColor: `${color}66` }}>
|
||||
<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} />
|
||||
{/* On/off is already represented by the power control, and stateless
|
||||
actions need no misleading unknown-state label beside their button. */}
|
||||
{(!power || !['on', 'off'].includes(entity.state)) && (entity.state !== 'unknown' || !entity.actions.length || !entity.available)
|
||||
? <ReadOnlyControl entity={entity} /> : null}
|
||||
{power ? <ToggleControl entity={{ name: `${entity.name}: Power`, state: !['off', 'unknown', 'unavailable'].includes(entity.state) }} disabled={disabled}
|
||||
onChange={(on) => onAction(on ? turnOn.id : turnOff.id, {})} /> : null}
|
||||
<div className="flex min-w-0 flex-wrap gap-0.5">
|
||||
{actions.map((action) => <ActionControls key={action.id} action={action} entityName={entity.name} disabled={disabled}
|
||||
onAction={onAction} hideButton={Boolean(power && (action === turnOn || action === turnOff))} />)}
|
||||
</div>
|
||||
{entity.unsupported.length ? <details className="text-[0.65rem] text-slate-400"><summary className="cursor-pointer">Other actions</summary>
|
||||
<p>These actions need inputs this panel cannot display: {entity.unsupported.join(', ')}.</p>
|
||||
</details> : null}
|
||||
{entity.details.length ? <details className="text-[0.65rem] text-slate-400"><summary className="cursor-pointer">Details</summary>
|
||||
{entity.details.map((detail) => <div key={detail.name} className="flex min-w-0 justify-between gap-1"><span>{detail.name}</span><span className="min-w-0 break-words text-right">{detail.value}</span></div>)}
|
||||
</details> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -42,17 +57,16 @@ export default function HomeAssistantActivitiesPanel() {
|
||||
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.
|
||||
// Cancel local typing timers on either browser or HA disconnection; cached
|
||||
// session values are still useful to read but must not authorize new writes.
|
||||
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}
|
||||
{connected && !state.controlsReady ? <p className="px-0.5 text-xs text-slate-400">Waiting for available controls.</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 className="grid grid-cols-[repeat(auto-fit,minmax(min(9rem,100%),1fr))] items-start gap-0.5">
|
||||
{state.items.map((entity) => <ActivityTile key={entity.id} entity={entity} connected={connected} controlsReady={state.controlsReady} allowed={allowed} admin={admin} />)}
|
||||
</div>
|
||||
</CardFrame>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
export default function useDraftControl(onChange, disabled) {
|
||||
const [draft, setDraft] = useState(null);
|
||||
const timer = useRef(null);
|
||||
const change = useRef(onChange);
|
||||
// A compound action may receive newer companion values while this field is
|
||||
// being typed. Use the current handler when the pause ends, not a snapshot
|
||||
// captured when the timer started.
|
||||
useEffect(() => { change.current = onChange; }, [onChange]);
|
||||
const cancel = useCallback(() => {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
@@ -19,9 +24,9 @@ export default function useDraftControl(onChange, disabled) {
|
||||
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);
|
||||
change.current(value);
|
||||
setDraft(null);
|
||||
}, [onChange, disabled, cancel]);
|
||||
}, [disabled, cancel]);
|
||||
|
||||
const edit = (value, debounce = true) => {
|
||||
cancel();
|
||||
|
||||
@@ -354,8 +354,8 @@ export function SessionProvider({ children }) {
|
||||
// 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 });
|
||||
homeAssistantActivityAct: (id, action, values = {}) => {
|
||||
if (socket.connected) socket.emit('homeAssistantActivities:act', { id, action, values });
|
||||
},
|
||||
homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }),
|
||||
homeAssistantSetState: (entityId, state) =>
|
||||
|
||||
Reference in New Issue
Block a user