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
10 changes: 9 additions & 1 deletion client/src/components/ModesHaSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { CheckCircle, HomeOutlined, LinkOff } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { AdminGroup, AdminRow } from './AdminRows.jsx';
import HaEntityPicker from './HaEntityPicker.jsx';
import PinModal from './PinModal';
import { useAdminPinPrompt } from '../hooks/useAdminPinPrompt.js';
import { HELPER_AUTOMATIC, HELPER_NORMAL } from '../utils/displayModes.js';

const mono = { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12.5 };
Expand Down Expand Up @@ -44,9 +46,14 @@ const ModesHaSettings = ({ modes, connection, entities, entitiesLoading, apiDevi
const [choosing, setChoosing] = useState(false);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState(null);
// Choosing the helper asks for the admin PIN when one is set; the server
// checks it too (routes/displayModesHa.js).
const { ask: askPin, pinModalProps } = useAdminPinPrompt({ wrongPin: t('admin:modes.ha.wrongPin') });
const ha = modes.ha;

const run = async (body, text) => {
const run = (body, text) => askPin(t('admin:modes.ha.pinTitle'), (pin) => send(pin ? { ...body, pin } : body, text));

const send = async (body, text) => {
setBusy(true);
setProblem(null);
try {
Expand Down Expand Up @@ -206,6 +213,7 @@ const ModesHaSettings = ({ modes, connection, entities, entitiesLoading, apiDevi
/>
)}
/>
<PinModal {...pinModalProps} />
</AdminGroup>
);
};
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,8 @@
"openConnections": "Open Connections",
"unreachable": "Home Assistant can't be reached right now. Modes are following their schedules until it's back.",
"failed": "That didn't work. Try again.",
"pinTitle": "Enter the admin PIN to change the mode helper",
"wrongPin": "Incorrect PIN. Please try again.",
"letHaSwitch": "Let Home Assistant switch modes",
"letHaSwitchHelp": "HomeGlow makes a dropdown helper in Home Assistant with Automatic, Normal and your modes. Automations set it to switch the wall; picking a mode on the wall sets it too.",
"defaultTitle": "HomeGlow display mode",
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/es/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,8 @@
"openConnections": "Abrir Conexiones",
"unreachable": "No se puede contactar con Home Assistant ahora mismo. Los modos siguen sus horarios hasta que vuelva.",
"failed": "No funcionó. Inténtalo de nuevo.",
"pinTitle": "Introduce el PIN de administrador para cambiar el ayudante de modo",
"wrongPin": "PIN incorrecto. Inténtalo de nuevo.",
"letHaSwitch": "Dejar que Home Assistant cambie los modos",
"letHaSwitchHelp": "HomeGlow crea un ayudante desplegable en Home Assistant con Automatic, Normal y tus modos. Las automatizaciones lo cambian para cambiar la pantalla; elegir un modo en la pantalla también lo cambia.",
"defaultTitle": "HomeGlow display mode",
Expand Down
10 changes: 8 additions & 2 deletions docs/reference/backend-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,15 @@ Every recipient (a device's browser push, an ntfy topic, a Home Assistant phone)
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/devices` | List devices with widget counts. |
| GET/PUT/PATCH | `/api/devices/:deviceName/settings` | Get/merge device settings JSON. |
| GET/PUT/PATCH | `/api/devices/:deviceName/settings` | Get/merge device settings JSON. `displayModes.ha.helper`, `helperId` and `title` (the mode helper) are kept as stored: only `POST …/modes/ha/helper` changes them. |
| PATCH | `/api/devices/:deviceName` | Rename a device. |
| POST | `/api/devices/:deviceName/copy-from/:sourceDeviceName` | Copy tabs + settings from another device. |
| POST | `/api/devices/:deviceName/copy-from/:sourceDeviceName` | Copy tabs + settings from another device. The device keeps its own mode helper. |
| GET | `/api/devices/:deviceName/modes/ha` | The Home Assistant states this display's modes read (only the entities its modes name). |
| GET | `/api/devices/:deviceName/modes/ha/entities` | Entities for Admin's pickers (`?kind=helper|alarm|people|any`). |
| POST | `/api/devices/:deviceName/modes/ha/helper` | The mode helper: make one (`{ title }`), use an existing `input_select` (`{ entity_id }`, its options are replaced) or stop using one (`{ entity_id: null }`). Needs the admin PIN (`pin`) when one is set; 404 for an unknown device. Demo-blocked. |
| POST | `/api/devices/:deviceName/modes/ha/sync` | Set the helper's options to Automatic, Normal and the modes. |
| POST | `/api/devices/:deviceName/modes/ha/select` | Set the helper to one of its options (`{ option }`). |
| POST | `/api/devices/:deviceName/modes/ha/report` | Post the showing mode (`{ mode, source, pinned }`) to `sensor.homeglow_*_showing`. |
| DELETE | `/api/devices/:deviceName` | Delete a device (cascades tabs and its notifications). |
| GET/PUT/DELETE | `/api/devices/:deviceName/notifications` | A device's push notifications: read, turn on or change (`subscription`, `events`, `members`, `language`), turn off. `events` may include `plugin:<id>` for an installed plugin that declares `notify`; reading lists those as `plugins`. |
| POST | `/api/devices/:deviceName/notifications/test` | Send the device a test notification. |
Expand Down
10 changes: 8 additions & 2 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2979,7 +2979,9 @@ const upsertDeviceSettings = async (request, reply) => {
ensureDeviceExists(deviceName);
const row = db.prepare('SELECT device_settings_json FROM devices WHERE name = ?').get(deviceName);
const existingSettings = parseJsonObject(row?.device_settings_json, {});
const merged = { ...existingSettings, ...incoming };
// The modes' Home Assistant helper is chosen only with the PIN
// (routes/displayModesHa.js), so a save keeps the one this display has.
const merged = displayModesHa.keepHelperLink({ ...existingSettings, ...incoming }, existingSettings);

db.prepare('UPDATE devices SET device_settings_json = ?, updateTime = CURRENT_TIMESTAMP WHERE name = ?').run(
JSON.stringify(merged),
Expand Down Expand Up @@ -3238,6 +3240,10 @@ fastify.post('/api/devices/:deviceName/copy-from/:sourceDeviceName', async (requ

ensureDeviceExists(deviceName);
const sourceDeviceSettings = db.prepare('SELECT device_settings_json FROM devices WHERE name = ?').get(sourceDeviceName);
// Everything but the modes' Home Assistant helper, which stays this
// display's own (chosen only with the PIN, routes/displayModesHa.js).
const targetSettings = parseJsonObject(db.prepare('SELECT device_settings_json FROM devices WHERE name = ?').get(deviceName)?.device_settings_json, {});
const copiedSettings = displayModesHa.keepHelperLink(parseJsonObject(sourceDeviceSettings?.device_settings_json, {}), targetSettings);

const copyTransaction = db.transaction(() => {
db.prepare('DELETE FROM tabs WHERE device_name = ?').run(deviceName);
Expand All @@ -3251,7 +3257,7 @@ fastify.post('/api/devices/:deviceName/copy-from/:sourceDeviceName', async (requ
`).run(deviceName, sourceDeviceName);

db.prepare('UPDATE devices SET device_settings_json = ?, updateTime = CURRENT_TIMESTAMP WHERE name = ?').run(
sourceDeviceSettings?.device_settings_json || '{}',
JSON.stringify(copiedSettings),
deviceName
);

Expand Down
12 changes: 10 additions & 2 deletions server/routes/displayModesHa.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
// alarm|people|any)
// POST /api/devices/:deviceName/modes/ha/helper make the mode helper
// ({ title }), or use one
// ({ entity_id }), or none
// ({ entity_id }), or none;
// needs the admin PIN ({ pin })
// POST /api/devices/:deviceName/modes/ha/sync its options = the modes
// POST /api/devices/:deviceName/modes/ha/select choose a helper option
// POST /api/devices/:deviceName/modes/ha/report post the showing mode
Expand All @@ -17,6 +18,7 @@

const rules = require('../services/displayModesHa');
const { readStates } = require('../services/haEntities');
const { checkAdminPin, refusePin } = require('../utils/adminPin');

function registerDisplayModesHaRoutes(fastify, {
db, live, ha, demoMode, readDevice, writeDevice, refreshWatched,
Expand Down Expand Up @@ -69,11 +71,17 @@ function registerDisplayModesHaRoutes(fastify, {

// Make the helper ({ title }), use an existing one ({ entity_id }), or stop
// using one ({ entity_id: null }). A helper HomeGlow made can be kept in
// step with the modes by id; one made elsewhere by set_options.
// step with the modes by id; one made elsewhere by set_options. The helper
// is what /sync and /select write to, and set_options replaces the options
// of whatever dropdown is named, so choosing one needs the admin PIN (asked
// for even on a display that remembers it, as backups are).
fastify.post('/api/devices/:deviceName/modes/ha/helper', async (request, reply) => {
if (unavailable(reply)) return undefined;
const { deviceName } = request.params;
const body = request.body || {};
if (!readDevice(deviceName)) return reply.status(404).send({ error: 'Device not found' });
const pinCheck = await checkAdminPin(db, typeof body.pin === 'string' ? body.pin : undefined);
if (!pinCheck.ok) return refusePin(reply, pinCheck, 'The admin PIN is needed to choose the mode helper.');
try {
if (body.entity_id === null) {
return { settings: saveHa(deviceName, { helper: null, helperId: null, title: null }) };
Expand Down
21 changes: 21 additions & 0 deletions server/services/displayModesHa.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ function haSettings(displayModes) {
};
}

// Which helper a display writes to is chosen only through POST
// /modes/ha/helper, which needs the admin PIN. Any other write of a display's
// settings keeps the link it has, or a settings save could point /sync and
// /select at any input_select in Home Assistant.
const HELPER_LINK_FIELDS = ['helper', 'helperId', 'title'];

/** `next` settings with `displayModes.ha`'s helper link taken from `existing`. */
function keepHelperLink(next, existing) {
if (!isObject(next) || !isObject(next.displayModes)) return next;
const before = isObject(existing?.displayModes?.ha) ? existing.displayModes.ha : {};
const linked = HELPER_LINK_FIELDS.filter((field) => before[field] !== undefined);
if (!isObject(next.displayModes.ha) && linked.length === 0) return next;
const ha = isObject(next.displayModes.ha) ? { ...next.displayModes.ha } : {};
HELPER_LINK_FIELDS.forEach((field) => {
if (before[field] !== undefined) ha[field] = before[field];
else delete ha[field];
});
return { ...next, displayModes: { ...next.displayModes, ha } };
}

/** The sensor HomeGlow posts the showing mode to, named after the helper. */
function sensorIdFor(ha) {
if (!ha.helper) return null;
Expand Down Expand Up @@ -101,6 +121,7 @@ module.exports = {
slugify,
helperOptions,
haSettings,
keepHelperLink,
sensorIdFor,
entityRefs,
trimState,
Expand Down
67 changes: 67 additions & 0 deletions server/tests/displayModesHaRoutes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,70 @@ test('an existing dropdown can be used instead, and the helper let go', async ()
const noHelper = await post(`${D}/modes/ha/select`, { option: 'Night' });
assert.equal(noHelper.status, 409);
});

// The helper is what /sync and /select write to, so only /helper (with the
// admin PIN) chooses it: a settings save can't point them at another dropdown.
test('a settings save keeps the helper the display has', async () => {
fake.setState('input_select.house_mode', 'Home', { options: ['Home', 'Away', 'Vacation'], friendly_name: 'House mode' });
const houseOptions = () => fake.states.get('input_select.house_mode').attributes.options;

const current = (await api(`${D}/settings`)).body.displayModes;
const sneaky = await patch(`${D}/settings`, { displayModes: { ...current, ha: { ...current.ha, helper: 'input_select.house_mode', helperId: 'house_mode' } } });
assert.equal(sneaky.status, 200, JSON.stringify(sneaky.body));
assert.equal(sneaky.body.displayModes.ha.helper, null, 'no helper taken from the save');
assert.equal(sneaky.body.displayModes.ha.helperId, null);
assert.equal((await post(`${D}/modes/ha/sync`, {})).body.synced, false);
assert.equal((await post(`${D}/modes/ha/select`, { option: 'Normal' })).status, 409);
assert.deepEqual(houseOptions(), ['Home', 'Away', 'Vacation'], 'the house helper is untouched');
assert.equal(fake.states.get('input_select.house_mode').state, 'Home');

const used = await post(`${D}/modes/ha/helper`, { entity_id: 'input_select.my_modes' });
assert.equal(used.status, 200, JSON.stringify(used.body));
const now = (await api(`${D}/settings`)).body.displayModes;
const saved = await put(`${D}/settings`, { displayModes: { ...now, ha: { ...now.ha, helper: 'input_select.house_mode', helperId: null, wake: ['person.sam'] } } });
assert.equal(saved.body.displayModes.ha.helper, 'input_select.my_modes', 'the chosen helper stays');
assert.deepEqual(saved.body.displayModes.ha.wake, ['person.sam'], 'the rest of ha saves');
const dropped = await patch(`${D}/settings`, { displayModes: { modes: now.modes } });
assert.equal(dropped.body.displayModes.ha.helper, 'input_select.my_modes', 'a save without ha keeps it too');
await post(`${D}/modes/ha/sync`, {});
assert.deepEqual(houseOptions(), ['Home', 'Away', 'Vacation']);
});

test('copying another display\'s settings keeps this display\'s own helper', async () => {
await patch('/api/devices/hall/settings', { displayModes: { modes } });
const copied = await post('/api/devices/hall/copy-from/kitchen-wall', {});
assert.equal(copied.status, 200, JSON.stringify(copied.body));
const hall = (await api('/api/devices/hall/settings')).body;
assert.deepEqual(hall.displayModes.modes.map((m) => m.id), ['morning', 'guest', 'night'], 'the modes are copied');
assert.equal(hall.displayModes.ha.helper, undefined, 'the kitchen\'s helper is not');
});

test('choosing the helper of an unknown display is refused, and makes no display', async () => {
const made = await post('/api/devices/not-a-display/modes/ha/helper', { entity_id: 'input_select.house_mode' });
assert.equal(made.status, 404);
const devices = (await api('/api/devices')).body;
assert.ok(!JSON.stringify(devices).includes('not-a-display'));
assert.deepEqual(fake.states.get('input_select.house_mode').attributes.options, ['Home', 'Away', 'Vacation']);
});

test('with an admin PIN set, choosing the helper needs it', async () => {
assert.equal((await post('/api/admin-pin/set', { pin: '2468' })).status, 200);
try {
// A display that says it remembers the PIN still has to give it.
await patch(`${D}/settings`, { adminPinRemembered: true });
const none = await post(`${D}/modes/ha/helper`, { entity_id: 'input_select.house_mode' });
assert.equal(none.status, 403, JSON.stringify(none.body));
assert.equal(none.body.needsPin, true);
const wrong = await post(`${D}/modes/ha/helper`, { entity_id: null, pin: '1111' });
assert.equal(wrong.status, 403);
assert.deepEqual(fake.states.get('input_select.house_mode').attributes.options, ['Home', 'Away', 'Vacation']);
assert.equal((await api(`${D}/settings`)).body.displayModes.ha.helper, 'input_select.my_modes', 'nothing changed');

const right = await post(`${D}/modes/ha/helper`, { entity_id: null, pin: '2468' });
assert.equal(right.status, 200, JSON.stringify(right.body));
assert.equal(right.body.settings.displayModes.ha.helper, null);
} finally {
await patch(`${D}/settings`, { adminPinRemembered: false });
await api('/api/admin-pin', { method: 'DELETE', body: JSON.stringify({ pin: '2468' }) });
}
});
Loading