diff --git a/client/src/components/ModesHaSettings.jsx b/client/src/components/ModesHaSettings.jsx
index 2c176c58..2ab67821 100644
--- a/client/src/components/ModesHaSettings.jsx
+++ b/client/src/components/ModesHaSettings.jsx
@@ -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 };
@@ -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 {
@@ -206,6 +213,7 @@ const ModesHaSettings = ({ modes, connection, entities, entitiesLoading, apiDevi
/>
)}
/>
+
);
};
diff --git a/client/src/i18n/locales/en/admin.json b/client/src/i18n/locales/en/admin.json
index 4cc9fee1..50ab13bc 100644
--- a/client/src/i18n/locales/en/admin.json
+++ b/client/src/i18n/locales/en/admin.json
@@ -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",
diff --git a/client/src/i18n/locales/es/admin.json b/client/src/i18n/locales/es/admin.json
index bbc47674..f4aaa875 100644
--- a/client/src/i18n/locales/es/admin.json
+++ b/client/src/i18n/locales/es/admin.json
@@ -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",
diff --git a/docs/reference/backend-api.md b/docs/reference/backend-api.md
index bece05fb..044feb69 100644
--- a/docs/reference/backend-api.md
+++ b/docs/reference/backend-api.md
@@ -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:` for an installed plugin that declares `notify`; reading lists those as `plugins`. |
| POST | `/api/devices/:deviceName/notifications/test` | Send the device a test notification. |
diff --git a/server/index.js b/server/index.js
index ffb30c9a..848dd392 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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),
@@ -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);
@@ -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
);
diff --git a/server/routes/displayModesHa.js b/server/routes/displayModesHa.js
index 2ed574d9..5ef19e06 100644
--- a/server/routes/displayModesHa.js
+++ b/server/routes/displayModesHa.js
@@ -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
@@ -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,
@@ -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 }) };
diff --git a/server/services/displayModesHa.js b/server/services/displayModesHa.js
index 36e9671b..887f42ec 100644
--- a/server/services/displayModesHa.js
+++ b/server/services/displayModesHa.js
@@ -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;
@@ -101,6 +121,7 @@ module.exports = {
slugify,
helperOptions,
haSettings,
+ keepHelperLink,
sensorIdFor,
entityRefs,
trimState,
diff --git a/server/tests/displayModesHaRoutes.test.js b/server/tests/displayModesHaRoutes.test.js
index 6e817452..36c0074a 100644
--- a/server/tests/displayModesHaRoutes.test.js
+++ b/server/tests/displayModesHaRoutes.test.js
@@ -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' }) });
+ }
+});