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' }) }); + } +});