Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ HomeGlow is built with a focus on modern aesthetics, user experience, and practi
- **Grid-Based System**: Responsive 12-column grid that adapts to mobile, tablet, and desktop screens
- **Persistent Layouts**: Your custom arrangement is automatically saved and restored
- **Touch & Mouse Support**: Full support for both touch gestures and traditional mouse interactions
- **Move Between Tabs**: In Move/Resize, drag a widget onto a tab in the dock, or hold it at the side of the screen to carry it to the next tab, like moving an app on a phone
- **Long-Press Menu**: In Move/Resize, hold a widget to move or duplicate it to another tab (or a new one), open its settings, or remove it from the tab (with Undo)
- **Stacks**: Several widgets in one spot, swiped between, while the rest of the dashboard stays put. Make one by holding a widget over another in Move/Resize, or under Admin → Widgets → Stacks. A Stack goes back to its first widget after a while, and can bring a widget to the front when Home Assistant says something needs attention (the grill card while the grill is on)
- **Display Modes**: A wall display can change through the day without anything moving: Morning, Dinner, Night and so on, each on a schedule or turned on from Home Assistant (a phone alarm going off, an entity's state, only while someone is home). A mode can start on a tab, dim the screen to off, and change what each Stack shows. HomeGlow can make a dropdown helper in Home Assistant to switch modes from automations, and reports which mode is showing. Set up under Admin → Displays → Modes
- **Takeovers**: A widget can take over the top or bottom half (or the whole screen) when Home Assistant says it needs you, then hand the wall back. Plugins can raise or close their own takeover with `HomeGlow.attention()`

### Enhanced Widgets
- **Widget Refresh System**: Configure auto-refresh rates for each widget independently (5, 15, 30 minutes, or 1-6 hours)
Expand Down
978 changes: 958 additions & 20 deletions client/src/app.jsx

Large diffs are not rendered by default.

58 changes: 50 additions & 8 deletions client/src/components/AdminPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ import { API_BASE_URL } from '../utils/apiConfig.js';
import { editWholeNumber, savedWholeNumber } from '../utils/wholeNumberField.js';
import { pinErrorText } from '../utils/pinErrors.js';
import { revealTabPicker } from '../utils/revealTabPicker.js';
import { nextExpandedPlugin, firstMissingPluginFilename } from '../utils/pluginAccordion.js';
import { EXPAND_PLUGIN_EVENT, nextExpandedPlugin, firstMissingPluginFilename } from '../utils/pluginAccordion.js';
import { getDeviceApiBase, getDeviceName, getLocalDeviceName, getLocalDeviceApiBase, getMirrorTarget, setDeviceName, isValidDeviceName } from '../utils/deviceName.js';
import { buildMirrorHash } from '../utils/mirrorMode.js';
import { getDeviceRowState } from '../utils/mirrorAdmin.js';
Expand All @@ -99,11 +99,14 @@ import { AdminFooter, AdminGroup, AdminNote, AdminRow } from './AdminRows.jsx';
import SoundPicker from './SoundPicker';
import ControlsOnDisplay from './ControlsOnDisplay';
import ExpandWidgetsSettings from './ExpandWidgetsSettings';
import ModesSettings from './ModesSettings.jsx';
import StacksSettings from './StacksSettings.jsx';
import { WIDGET_STACKS_KEY, isStackName, normalizeStacks, stackName } from '../utils/widgetStacks.js';
import { frameNameFor } from '../utils/pluginLocalStore.js';
import useFetchTabs from '../hooks/useFetchTabs.js';
import { MAX_DOCK_HIDE_MINUTES, screensaverPreemptsDock } from '../utils/dockIdleHide.js';
import useIsMobile from '../hooks/useIsMobile.js';
import { syncWidgetAssignments } from '../utils/assignmentSync.js';
import { isHiddenInStack, syncWidgetAssignments } from '../utils/assignmentSync.js';
import { normalizeWidgetSettings as normalizeSharedWidgetSettings } from '../utils/widgetSettings.js';
import { stackableTableSx } from '../utils/responsiveTable.js';
import {
Expand Down Expand Up @@ -307,6 +310,14 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
// expand button, so toggling it never expands the row.
const [expandedPlugin, setExpandedPlugin] = useState(null);
const toggleExpandedPlugin = (filename) => setExpandedPlugin((current) => nextExpandedPlugin(current, filename));
// A widget menu on the dashboard asking for one plugin's settings.
useEffect(() => {
const expand = (event) => {
if (typeof event.detail?.filename === 'string') setExpandedPlugin(event.detail.filename);
};
window.addEventListener(EXPAND_PLUGIN_EVENT, expand);
return () => window.removeEventListener(EXPAND_PLUGIN_EVENT, expand);
}, []);
const [loadingGithub, setLoadingGithub] = useState(false);
// The store list loads itself on opening Plugins (issue #273): whether it
// has, and why not when it couldn't.
Expand All @@ -327,6 +338,9 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
const [checkingPin, setCheckingPin] = useState(true);
const { tabs, fetchTabs } = useFetchTabs(API_DEVICE_URL);
const [widgetAssignments, setWidgetAssignments] = useState({});
// Widgets shown inside a Stack that's on a tab: on the dashboard, so not
// "enabled but on no tab" (utils/widgetStacks.js).
const [stackedWidgets, setStackedWidgets] = useState(() => new Set());
const [pluginSettings, setPluginSettings] = useState({});
const [pluginAssignments, setPluginAssignments] = useState({});
// Values for settings a plugin declared in its manifest (issue #105 Phase 2),
Expand Down Expand Up @@ -645,6 +659,8 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
const coreAssignments = {};
const pluginAssign = {};
assignments.forEach(assignment => {
// Shown in a Stack there, not on that tab on its own.
if (isHiddenInStack(assignment, assignments)) return;
if (assignment.widget_name.startsWith('plugin:')) {
if (!pluginAssign[assignment.widget_name]) {
pluginAssign[assignment.widget_name] = [];
Expand All @@ -660,6 +676,16 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ

setWidgetAssignments(coreAssignments);
setPluginAssignments(pluginAssign);
try {
const { data } = await axios.get(`${API_DEVICE_URL}/settings`);
const placed = new Set(assignments.map((row) => row.widget_name).filter(isStackName));
const members = normalizeStacks(data?.[WIDGET_STACKS_KEY]).stacks
.filter((stack) => placed.has(stackName(stack.id)))
.flatMap((stack) => stack.widgets);
setStackedWidgets(new Set(members));
} catch {
setStackedWidgets(new Set());
}
} catch (error) {
console.error('Error fetching widget assignments:', error);
setWidgetAssignments({});
Expand Down Expand Up @@ -901,7 +927,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
return Object.keys(DEFAULT_WIDGET_SETTINGS).filter((widgetName) => {
const isEnabled = Boolean(normalizedSettings?.[widgetName]?.enabled);
const selectedTabNumbers = assignmentsToValidate?.[widgetName];
return isEnabled && (!Array.isArray(selectedTabNumbers) || selectedTabNumbers.length === 0);
return isEnabled && !stackedWidgets.has(widgetName) && (!Array.isArray(selectedTabNumbers) || selectedTabNumbers.length === 0);
});
};

Expand All @@ -919,7 +945,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ

const pluginWidgetName = `plugin:${filename}`;
const selectedTabNumbers = assignmentsToValidate?.[pluginWidgetName];
if (!Array.isArray(selectedTabNumbers) || selectedTabNumbers.length === 0) {
if (!stackedWidgets.has(pluginWidgetName) && (!Array.isArray(selectedTabNumbers) || selectedTabNumbers.length === 0)) {
missing.push(pluginWidgetName);
}

Expand Down Expand Up @@ -2053,7 +2079,8 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ

{['chores', 'calendar', 'photos', 'weather'].filter((widget) => widgetSettings[widget]).map((widget) => {
const config = widgetSettings[widget];
const hasRequiredTabsError = Boolean(config.enabled) && (!Array.isArray(widgetAssignments[widget]) || widgetAssignments[widget].length === 0);
const inStack = stackedWidgets.has(widget);
const hasRequiredTabsError = Boolean(config.enabled) && !inStack && (!Array.isArray(widgetAssignments[widget]) || widgetAssignments[widget].length === 0);
return (
<AdminGroup
key={widget}
Expand Down Expand Up @@ -2100,7 +2127,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
label={t('admin:widgets.showOnTabs')}
hint={hasRequiredTabsError
? <Box component="span" sx={{ color: 'error.main' }}>Required: select at least one tab when this widget is enabled.</Box>
: 'Select which tabs this widget should appear on.'}
: (inStack ? t('admin:stacks.alsoInStack') : 'Select which tabs this widget should appear on.')}
wide
control={(
<Box sx={{ width: '100%' }} data-tab-picker={widget}>
Expand Down Expand Up @@ -2130,6 +2157,15 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
);
})}

{/* Stacks: several widgets in one spot (utils/widgetStacks.js). */}
<StacksSettings
onOpenConnections={() => navigate({ tab: 'system', section: 'connections' })}
onChanged={async () => {
await fetchWidgetAssignments();
await onTabsChanged?.();
}}
/>

<AdminFooter>
<Button variant="contained" onClick={() => saveWidgetSettings()} startIcon={<Save />}>
{t('admin:widgets.saveSettings')}
Expand Down Expand Up @@ -2378,7 +2414,8 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
const storeEntry = plugin.source === 'builder' ? null : storeEntryOf(plugin, githubWidgets);
const updatable = storeEntry && pluginStoreStatus(storeEntry, [plugin]) === 'update';
const pluginWidgetName = `plugin:${plugin.filename}`;
const hasRequiredTabsError = Boolean(pSettings.enabled) && (!Array.isArray(pluginAssignments[pluginWidgetName]) || pluginAssignments[pluginWidgetName].length === 0);
const inStack = stackedWidgets.has(pluginWidgetName);
const hasRequiredTabsError = Boolean(pSettings.enabled) && !inStack && (!Array.isArray(pluginAssignments[pluginWidgetName]) || pluginAssignments[pluginWidgetName].length === 0);
const setPluginField = (field, value) => setPluginSettings(prev => ({
...prev,
[plugin.filename]: { ...prev[plugin.filename], [field]: value }
Expand Down Expand Up @@ -2588,7 +2625,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
label={t('admin:widgets.showOnTabs')}
hint={hasRequiredTabsError
? <Box component="span" sx={{ color: 'error.main' }}>Required: select at least one tab when this plugin is enabled.</Box>
: 'Select which tabs this plugin should appear on.'}
: (inStack ? t('admin:stacks.alsoInStack') : 'Select which tabs this plugin should appear on.')}
wide
control={(
<Box sx={{ width: '100%' }} data-tab-picker={pluginWidgetName}>
Expand Down Expand Up @@ -2948,6 +2985,11 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
</>
)}

{/* Modes: this display's own modes (utils/displayModes.js). */}
{location.section === 'modes' && (
<ModesSettings onOpenConnections={() => navigate({ tab: 'system', section: 'connections' })} />
)}

{location.section === 'screensaver' && (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, px: 0.5 }}>
Expand Down
99 changes: 99 additions & 0 deletions client/src/components/HaEntityPicker.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from 'react';
import { Autocomplete, Box, Chip, TextField, Typography } from '@mui/material';

// Which entities each picker offers. Next-alarm sensors come first for alarms.
export const ENTITY_KINDS = {
helper: (e) => e.entity_id.startsWith('input_select.'),
alarm: (e) => e.entity_id.startsWith('sensor.') && (e.device_class === 'timestamp' || /alarm/.test(e.entity_id)),
people: (e) => /^(person|device_tracker|zone)\./.test(e.entity_id),
any: () => true,
};

const alarmRank = (e) => (/next_alarm/.test(e.entity_id) ? 0 : 1);

export function entitiesOfKind(entities, kind) {
const list = (entities || []).filter(ENTITY_KINDS[kind] || ENTITY_KINDS.any);
if (kind === 'alarm') return [...list].sort((a, b) => alarmRank(a) - alarmRank(b) || a.name.localeCompare(b.name));
return list;
}

const renderOption = (props, option) => {
const { key, ...rest } = props;
return (
<Box component="li" key={key} {...rest} sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography noWrap sx={{ fontWeight: 600 }}>{option.name}</Typography>
<Typography noWrap variant="body2" color="text.secondary" sx={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}>
{option.entity_id}
</Typography>
</Box>
{option.state != null && option.state !== '' && (
<Chip size="small" label={String(option.state).length > 22 ? `${String(option.state).slice(0, 20)}…` : option.state} />
)}
</Box>
);
};

/**
* Pick Home Assistant entities by friendly name, with their id and state.
* `entities` is [{ entity_id, name, state, device_class }]; `value` an entity
* id (or ids, with `multiple`).
*/
// The states worth offering for "when <entity> is …".
const STATE_SUGGESTIONS = {
binary_sensor: ['on', 'off'],
input_boolean: ['on', 'off'],
switch: ['on', 'off'],
light: ['on', 'off'],
fan: ['on', 'off'],
person: ['home', 'not_home'],
device_tracker: ['home', 'not_home'],
lock: ['locked', 'unlocked'],
cover: ['open', 'closed'],
media_player: ['playing', 'paused', 'idle', 'off'],
alarm_control_panel: ['disarmed', 'armed_home', 'armed_away', 'armed_night', 'triggered'],
vacuum: ['cleaning', 'docked', 'returning', 'error'],
};
export const statesFor = (entity) => {
if (!entity) return [];
if (Array.isArray(entity.options)) return entity.options;
const domain = entity.entity_id.split('.')[0];
const list = STATE_SUGGESTIONS[domain] || [];
return entity.state && !list.includes(entity.state) ? [...list, entity.state] : list;
};

const HaEntityPicker = ({ entities, kind = 'any', value, onChange, label, placeholder, helperText, multiple = false, loading = false, error = false, sx }) => {
const options = entitiesOfKind(entities, kind);
const byId = new Map((entities || []).map((e) => [e.entity_id, e]));
// An id the list doesn't have (Home Assistant away, or renamed) still shows.
const known = (id) => byId.get(id) || { entity_id: id, name: id, state: null };
const selected = multiple ? (value || []).map(known) : (value ? known(value) : null);

return (
<Autocomplete
multiple={multiple}
options={options}
value={selected}
loading={loading}
onChange={(_, next) => onChange(multiple ? next.map((e) => e.entity_id) : (next?.entity_id || null))}
getOptionLabel={(option) => option?.name || option?.entity_id || ''}
isOptionEqualToValue={(a, b) => a.entity_id === b.entity_id}
filterOptions={(list, { inputValue }) => {
const q = inputValue.trim().toLowerCase();
if (!q) return list.slice(0, 200);
return list.filter((e) => e.name.toLowerCase().includes(q) || e.entity_id.includes(q)).slice(0, 200);
}}
renderOption={renderOption}
renderValue={multiple ? (items, getItemProps) => items.map((item, index) => {
const { key, ...rest } = getItemProps({ index });
return <Chip key={key} size="small" label={item.name} {...rest} />;
}) : undefined}
renderInput={(params) => (
<TextField {...params} label={label} placeholder={placeholder} helperText={helperText} error={error} />
)}
sx={sx}
/>
);
};

export default HaEntityPicker;
5 changes: 4 additions & 1 deletion client/src/components/HomeAssistantPanels.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const conditionColumns = {

// The preview is drawn at the panel's real size, shrunk to fit this box.
const PREVIEW_MAX = 480;
// The "Add devices" box always starts with nothing picked. One shared empty
// list: a new [] on every redraw reads as a new pick and clears the typing.
const NOTHING_PICKED = [];
// The width the size pickers need.
const PICKER_MIN_WIDTH = 260;
const range = (n) => Array.from({ length: n }, (_, i) => i + 1);
Expand Down Expand Up @@ -547,7 +550,7 @@ const HomeAssistantPanels = ({ onNavigate }) => {
</li>
);
}}
value={[]}
value={NOTHING_PICKED}
disabled={draft.recipe.tiles.length >= MAX_TILES}
onChange={(_, value) => updateRecipe({ tiles: addTiles(draft.recipe.tiles, value) })}
renderInput={(params) => <TextField {...params} label={t('admin:haPanels.addEntities')} helperText={t('admin:haPanels.addEntitiesHelp')} />}
Expand Down
Loading
Loading