UNPKG

homebridge-eufy-security

Version:
611 lines (521 loc) 22.2 kB
/** * Device Detail View — per-device/station settings with progressive disclosure. * Simple mode shows 3-4 toggles, Advanced expands full options. */ // eslint-disable-next-line no-unused-vars const DeviceDetailView = { _container: null, _advancedOpen: false, _expertOpen: false, /** * @param {HTMLElement} container * @param {string} type - 'device' or 'station' * @param {string} id - uniqueId / serial number */ async render(container, type, id) { this._container = container; this._advancedOpen = false; this._expertOpen = false; container.innerHTML = ''; const config = await Config.get(); const { station, device } = this._findAccessory(type, id); if (!station && !device) { container.innerHTML = ` <div class="text-center text-muted py-5"> <p>Device not found.</p> <button class="btn btn-outline-secondary btn-sm" id="btn-back">Back to Dashboard</button> </div>`; container.querySelector('#btn-back').addEventListener('click', () => App.navigate('dashboard')); return; } // For unsupported standalone stations routed as 'device', the device is null — use station const accessory = (type === 'station' ? station : device) || station; const isUnsupported = accessory && accessory.unsupported === true; // Redirect unsupported devices to their dedicated view if (isUnsupported) { App.navigate('unsupported/' + accessory.uniqueId); return; } // Header with back button + device image this._renderHeader(container, accessory, type); // Main content area const content = document.createElement('div'); if (type === 'device' && device) { const accessoryConfig = type === 'station' ? Config.getStationConfig(id) : Config.getDeviceConfig(id); this._renderDeviceSettings(content, device, accessoryConfig || {}, config); // For standalone devices, also show station settings (if station supports security control) if (device.standalone && station && !station.noSecurityControl) { const stationConfig = Config.getStationConfig(station.uniqueId) || {}; this._renderStandaloneStationSection(content, station, stationConfig, config); } } else if (type === 'station' && station) { const accessoryConfig = Config.getStationConfig(id); this._renderStationSettings(content, station, accessoryConfig || {}, config); } container.appendChild(content); }, // ===== Header ===== _renderHeader(container, accessory, type) { const header = document.createElement('div'); header.className = 'detail-header'; const backBtn = document.createElement('button'); backBtn.className = 'btn btn-link p-0'; backBtn.innerHTML = '← Back'; backBtn.style.textDecoration = 'none'; backBtn.addEventListener('click', () => App.navigate('dashboard')); const info = document.createElement('div'); info.className = 'detail-header__info'; info.innerHTML = ` <h5>${this._escHtml(accessory.displayName)}</h5> <small>${accessory.typename || ('Type ' + accessory.type)} · ${accessory.uniqueId}</small> `; header.appendChild(backBtn); const img = document.createElement('img'); img.className = 'detail-header__image'; img.src = DeviceImages.getPath(accessory.type); img.alt = accessory.displayName; header.appendChild(img); header.appendChild(info); container.appendChild(header); }, // ===== Device Settings (Camera/Doorbell/Sensor/Lock) ===== _renderDeviceSettings(content, device, deviceConfig, config) { const ignoreDevices = config.ignoreDevices || []; const isIgnored = ignoreDevices.includes(device.uniqueId); // ── Enable in HomeKit (always visible) ── const enableSection = document.createElement('div'); enableSection.className = 'detail-section'; const enableTitle = document.createElement('div'); enableTitle.className = 'detail-section__title'; enableTitle.textContent = 'Device Settings'; enableSection.appendChild(enableTitle); Toggle.render(enableSection, { id: 'toggle-enable', label: 'Enable in HomeKit', help: 'When disabled, this device will not appear in the Home app.', checked: !isIgnored, onChange: async (checked) => { await Config.toggleIgnore(device.uniqueId, !checked, 'device'); const rest = content.querySelector('#device-rest-settings'); if (rest) rest.style.display = checked ? '' : 'none'; }, }); content.appendChild(enableSection); // Wrapper for all settings below Enable toggle — hidden when device is ignored const restSettings = document.createElement('div'); restSettings.id = 'device-rest-settings'; if (isIgnored) restSettings.style.display = 'none'; // ── Simple Settings ── const simpleSection = document.createElement('div'); simpleSection.className = 'detail-section'; // Camera-specific simple settings if (device.isCamera) { Toggle.render(simpleSection, { id: 'toggle-camera-enable', label: 'Camera Feed', help: 'Show camera feed in HomeKit. Disable to expose only sensors.', checked: deviceConfig.enableCamera !== false, onChange: async (checked) => { await Config.updateDeviceConfig(device.uniqueId, { enableCamera: checked }); }, }); if (device.DeviceMotionDetection) { Toggle.render(simpleSection, { id: 'toggle-motion', label: 'Motion Detection', help: 'Expose motion detection as a sensor in HomeKit.', checked: deviceConfig.motionButton !== false, onChange: async (checked) => { await Config.updateDeviceConfig(device.uniqueId, { motionButton: checked }); }, }); } } restSettings.appendChild(simpleSection); // ── Advanced Settings (collapsed by default) ── if (device.isCamera) { this._renderAdvancedToggle(restSettings, () => { this._advancedOpen = !this._advancedOpen; const advSection = content.querySelector('#advanced-section'); if (advSection) advSection.style.display = this._advancedOpen ? 'block' : 'none'; const chevron = content.querySelector('.advanced-toggle__chevron'); if (chevron) chevron.classList.toggle('advanced-toggle__chevron--open', this._advancedOpen); }); const advSection = document.createElement('div'); advSection.id = 'advanced-section'; advSection.style.display = 'none'; // ── Streaming ── const streamTitle = document.createElement('div'); streamTitle.className = 'detail-section__title'; streamTitle.textContent = 'Streaming'; advSection.appendChild(streamTitle); if (device.supportsRTSP) { Toggle.render(advSection, { id: 'toggle-rtsp', label: 'RTSP Streaming', help: 'Use RTSP stream instead of P2P. Requires the camera to have RTSP enabled in the Eufy app.', checked: !!deviceConfig.rtsp, onChange: async (checked) => { await Config.updateDeviceConfig(device.uniqueId, { rtsp: checked }); }, }); } if (device.supportsTalkback) { Toggle.render(advSection, { id: 'toggle-talkback', label: 'Two-Way Audio', help: 'Enable talkback / two-way audio in HomeKit.', checked: !!deviceConfig.talkback, onChange: async (checked) => { await Config.updateDeviceConfig(device.uniqueId, { talkback: checked }); }, }); } // ── HomeKit Secure Video ── const hsvTitle = document.createElement('div'); hsvTitle.className = 'detail-section__title'; hsvTitle.textContent = 'HomeKit Secure Video'; advSection.appendChild(hsvTitle); NumberInput.render(advSection, { id: 'num-hsv-duration', label: 'HSV Recording Duration', help: 'Maximum recording duration in seconds for HSV clips.', value: deviceConfig.hsvRecordingDuration || 90, min: 10, max: 300, step: 10, suffix: 'sec', onChange: async (val) => { await Config.updateDeviceConfig(device.uniqueId, { hsvRecordingDuration: val }); }, }); // ── Snapshots ── const snapTitle = document.createElement('div'); snapTitle.className = 'detail-section__title'; snapTitle.textContent = 'Snapshots'; advSection.appendChild(snapTitle); Select.render(advSection, { id: 'select-snapshot-method', label: 'Snapshot Method', help: 'How to capture snapshots. "Auto" lets the plugin decide the best method.', options: [ { value: '0', label: 'Auto' }, { value: '1', label: 'From Livestream' }, { value: '2', label: 'From Cloud' }, { value: '3', label: 'Preload on Event' }, ], value: String(deviceConfig.snapshotHandlingMethod || 0), onChange: async (val) => { await Config.updateDeviceConfig(device.uniqueId, { snapshotHandlingMethod: parseInt(val) }); }, }); Toggle.render(advSection, { id: 'toggle-delay-snapshot', label: 'Delay Snapshot Capture', help: 'Wait briefly before taking a snapshot to get a clearer image.', checked: !!deviceConfig.delayCameraSnapshot, onChange: async (checked) => { await Config.updateDeviceConfig(device.uniqueId, { delayCameraSnapshot: checked }); }, }); // ── Camera Buttons ── const btnTitle = document.createElement('div'); btnTitle.className = 'detail-section__title'; btnTitle.textContent = 'Camera Buttons'; advSection.appendChild(btnTitle); Toggle.render(advSection, { id: 'toggle-enable-btn', label: 'Enable Switch', help: 'Add a switch to enable/disable the camera from HomeKit.', checked: !!deviceConfig.enableButton, onChange: async (checked) => { await Config.updateDeviceConfig(device.uniqueId, { enableButton: checked }); }, }); if (device.DeviceLight) { Toggle.render(advSection, { id: 'toggle-light-btn', label: 'Light Switch', help: 'Add a switch to control the camera\'s spotlight/floodlight.', checked: !!deviceConfig.lightButton, onChange: async (checked) => { await Config.updateDeviceConfig(device.uniqueId, { lightButton: checked }); }, }); } if (device.DeviceChimeIndoor) { Toggle.render(advSection, { id: 'toggle-chime-btn', label: 'Indoor Chime Switch', help: 'Add a switch to control the indoor chime.', checked: !!deviceConfig.indoorChimeButton, onChange: async (checked) => { await Config.updateDeviceConfig(device.uniqueId, { indoorChimeButton: checked }); }, }); } // ── Expert: Video Config ── this._renderExpertToggle(advSection, () => { this._expertOpen = !this._expertOpen; const expertSection = advSection.querySelector('#expert-section'); if (expertSection) expertSection.style.display = this._expertOpen ? 'block' : 'none'; const chevron = advSection.querySelector('#expert-chevron'); if (chevron) chevron.classList.toggle('advanced-toggle__chevron--open', this._expertOpen); }); const expertSection = document.createElement('div'); expertSection.id = 'expert-section'; expertSection.style.display = 'none'; const videoTitle = document.createElement('div'); videoTitle.className = 'detail-section__title'; videoTitle.textContent = 'Video Encoding (Expert)'; expertSection.appendChild(videoTitle); const vc = deviceConfig.videoConfig || {}; NumberInput.render(expertSection, { id: 'num-maxWidth', label: 'Max Width', help: 'Maximum video width in pixels.', value: vc.maxWidth || 1920, min: 320, max: 3840, step: 160, suffix: 'px', onChange: async (val) => { const existing = deviceConfig.videoConfig || {}; await Config.updateDeviceConfig(device.uniqueId, { videoConfig: { ...existing, maxWidth: val } }); }, }); NumberInput.render(expertSection, { id: 'num-maxHeight', label: 'Max Height', help: 'Maximum video height in pixels.', value: vc.maxHeight || 1080, min: 240, max: 2160, step: 120, suffix: 'px', onChange: async (val) => { const existing = deviceConfig.videoConfig || {}; await Config.updateDeviceConfig(device.uniqueId, { videoConfig: { ...existing, maxHeight: val } }); }, }); NumberInput.render(expertSection, { id: 'num-maxFPS', label: 'Max FPS', help: 'Maximum frames per second.', value: vc.maxFPS || 30, min: 10, max: 60, step: 5, onChange: async (val) => { const existing = deviceConfig.videoConfig || {}; await Config.updateDeviceConfig(device.uniqueId, { videoConfig: { ...existing, maxFPS: val } }); }, }); NumberInput.render(expertSection, { id: 'num-maxBitrate', label: 'Max Bitrate', help: 'Maximum video bitrate in kbps.', value: vc.maxBitrate || 1800, min: 300, max: 8000, step: 100, suffix: 'kbps', onChange: async (val) => { const existing = deviceConfig.videoConfig || {}; await Config.updateDeviceConfig(device.uniqueId, { videoConfig: { ...existing, maxBitrate: val } }); }, }); advSection.appendChild(expertSection); restSettings.appendChild(advSection); } content.appendChild(restSettings); }, // ===== Station Settings ===== _renderStationSettings(content, station, stationConfig, config) { const ignoreStations = config.ignoreStations || []; const isIgnored = ignoreStations.includes(station.uniqueId); // Simple const section = document.createElement('div'); section.className = 'detail-section'; const title = document.createElement('div'); title.className = 'detail-section__title'; title.textContent = 'Station Settings'; section.appendChild(title); Toggle.render(section, { id: 'toggle-station-enable', label: 'Enable in HomeKit', help: 'When disabled, this station\'s security panel will not appear in HomeKit.', checked: !isIgnored, onChange: async (checked) => { await Config.toggleIgnore(station.uniqueId, !checked, 'station'); const rest = content.querySelector('#station-rest-settings'); if (rest) rest.style.display = checked ? '' : 'none'; }, }); content.appendChild(section); // Wrapper for all settings below Enable toggle — hidden when station is ignored const stationRest = document.createElement('div'); stationRest.id = 'station-rest-settings'; if (isIgnored) stationRest.style.display = 'none'; // Advanced — Guard Modes Mapping this._renderAdvancedToggle(stationRest, () => { this._advancedOpen = !this._advancedOpen; const advSection = content.querySelector('#advanced-section'); if (advSection) advSection.style.display = this._advancedOpen ? 'block' : 'none'; const chevron = content.querySelector('.advanced-toggle__chevron'); if (chevron) chevron.classList.toggle('advanced-toggle__chevron--open', this._advancedOpen); }); const advSection = document.createElement('div'); advSection.id = 'advanced-section'; advSection.style.display = 'none'; const guardTitle = document.createElement('div'); guardTitle.className = 'detail-section__title'; guardTitle.textContent = 'Guard Mode Mapping'; advSection.appendChild(guardTitle); const guardHelp = document.createElement('p'); guardHelp.className = 'text-muted'; guardHelp.style.fontSize = '0.8rem'; guardHelp.textContent = 'Map HomeKit security modes to Eufy guard modes for this station.'; advSection.appendChild(guardHelp); GuardModes.render(advSection, { hkHome: stationConfig.hkHome ?? config.hkHome ?? 1, hkAway: stationConfig.hkAway ?? config.hkAway ?? 0, hkNight: stationConfig.hkNight ?? config.hkNight ?? 1, hkOff: stationConfig.hkOff ?? config.hkOff ?? 63, onChange: async (modes) => { await Config.updateStationConfig(station.uniqueId, modes); }, }); // Manual Alarm const alarmTitle = document.createElement('div'); alarmTitle.className = 'detail-section__title mt-3'; alarmTitle.textContent = 'Manual Alarm'; advSection.appendChild(alarmTitle); NumberInput.render(advSection, { id: 'num-alarm-seconds', label: 'Alarm Duration', help: 'How long (in seconds) a manually triggered alarm should sound.', value: stationConfig.manualAlarmSeconds || 30, min: 5, max: 300, step: 5, suffix: 'sec', onChange: async (val) => { await Config.updateStationConfig(station.uniqueId, { manualAlarmSeconds: val }); }, }); stationRest.appendChild(advSection); content.appendChild(stationRest); }, // ===== Standalone Station Settings (shown in device detail for standalone devices) ===== _renderStandaloneStationSection(content, station, stationConfig, config) { const ignoreStations = config.ignoreStations || []; const isIgnored = ignoreStations.includes(station.uniqueId); // Divider const divider = document.createElement('hr'); divider.className = 'my-4'; content.appendChild(divider); // Enable toggle section const section = document.createElement('div'); section.className = 'detail-section'; const title = document.createElement('div'); title.className = 'detail-section__title'; title.textContent = 'Security Panel'; section.appendChild(title); const helpText = document.createElement('p'); helpText.className = 'text-muted'; helpText.style.fontSize = '0.8rem'; helpText.textContent = 'This device acts as its own station. Configure the security panel independently from the device.'; section.appendChild(helpText); Toggle.render(section, { id: 'toggle-station-enable', label: 'Enable Security Panel', help: 'When disabled, the security panel will not appear in HomeKit. The device itself remains active.', checked: !isIgnored, onChange: async (checked) => { await Config.toggleIgnore(station.uniqueId, !checked, 'station'); const rest = content.querySelector('#standalone-station-rest'); if (rest) rest.style.display = checked ? '' : 'none'; }, }); content.appendChild(section); // Rest of station settings (hidden when disabled) const stationRest = document.createElement('div'); stationRest.id = 'standalone-station-rest'; if (isIgnored) stationRest.style.display = 'none'; // Guard Mode Mapping const guardSection = document.createElement('div'); guardSection.className = 'detail-section'; const guardTitle = document.createElement('div'); guardTitle.className = 'detail-section__title'; guardTitle.textContent = 'Guard Mode Mapping'; guardSection.appendChild(guardTitle); const guardHelp = document.createElement('p'); guardHelp.className = 'text-muted'; guardHelp.style.fontSize = '0.8rem'; guardHelp.textContent = 'Map HomeKit security modes to Eufy guard modes.'; guardSection.appendChild(guardHelp); GuardModes.render(guardSection, { hkHome: stationConfig.hkHome ?? config.hkHome ?? 1, hkAway: stationConfig.hkAway ?? config.hkAway ?? 0, hkNight: stationConfig.hkNight ?? config.hkNight ?? 1, hkOff: stationConfig.hkOff ?? config.hkOff ?? 63, onChange: async (modes) => { await Config.updateStationConfig(station.uniqueId, modes); }, }); // Manual Alarm const alarmTitle = document.createElement('div'); alarmTitle.className = 'detail-section__title mt-3'; alarmTitle.textContent = 'Manual Alarm'; guardSection.appendChild(alarmTitle); NumberInput.render(guardSection, { id: 'num-station-alarm-seconds', label: 'Alarm Duration', help: 'How long (in seconds) a manually triggered alarm should sound.', value: stationConfig.manualAlarmSeconds || 30, min: 5, max: 300, step: 5, suffix: 'sec', onChange: async (val) => { await Config.updateStationConfig(station.uniqueId, { manualAlarmSeconds: val }); }, }); stationRest.appendChild(guardSection); content.appendChild(stationRest); }, // ===== Helpers ===== _findAccessory(type, id) { const stations = App.state.stations || []; let foundStation = null; let foundDevice = null; for (const s of stations) { if (s.uniqueId === id) foundStation = s; for (const d of s.devices || []) { if (d.uniqueId === id) { foundDevice = d; foundStation = s; } } } return { station: foundStation, device: foundDevice }; }, _renderAdvancedToggle(container, onClick) { const btn = document.createElement('button'); btn.className = 'advanced-toggle'; btn.innerHTML = ` <span class="advanced-toggle__chevron">▶</span> Advanced Settings `; btn.addEventListener('click', onClick); container.appendChild(btn); }, _renderExpertToggle(container, onClick) { const btn = document.createElement('button'); btn.className = 'advanced-toggle'; btn.innerHTML = ` <span class="advanced-toggle__chevron" id="expert-chevron">▶</span> Video Encoding (Expert) `; btn.addEventListener('click', onClick); container.appendChild(btn); }, _escHtml(str) { return Helpers.escHtml(str); }, };