diff --git a/README.md b/README.md index 87aa06c..77b37f6 100755 --- a/README.md +++ b/README.md @@ -68,3 +68,34 @@ POST /api.php?action=save-entity-override } ``` +## Empty room slots + +Для desktop-раскладки можно создавать пустые слоты: + +```bash +POST /api.php?action=create-room-layout-item +{ + "room_id": "living_room" +} +``` + +Перемещение: + +```bash +POST /api.php?action=save-room-layout-item +{ + "room_id": "living_room", + "layout_item_id": "slot_xxx", + "order": 120 +} +``` + +Удаление: + +```bash +POST /api.php?action=delete-room-layout-item +{ + "room_id": "living_room", + "layout_item_id": "slot_xxx" +} +``` diff --git a/api.php b/api.php index bfc8258..d551e2d 100755 --- a/api.php +++ b/api.php @@ -127,10 +127,82 @@ try { 'icon' => array_key_exists('icon', $payload) ? (string)$payload['icon'] : null, ]; + if (array_key_exists('temperature_sensor_entity_id', $payload)) { + $patch['temperature_sensor_entity_id'] = (string)$payload['temperature_sensor_entity_id']; + } + $config = app_update_room_override($config, $roomId, $patch); api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); } + if ($action === 'create-room-layout-item') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + + if ($roomId === '') { + api_json(['ok' => false, 'error' => 'room_id is required'], 400); + } + + $layoutItemId = trim((string)($payload['layout_item_id'] ?? '')); + if ($layoutItemId === '') { + $layoutItemId = 'slot_' . str_replace('.', '', uniqid('', true)); + } + + $order = array_key_exists('order', $payload) ? (int)$payload['order'] : null; + $config = app_update_room_layout_item($config, $roomId, $layoutItemId, [ + 'order' => $order, + ]); + + api_json([ + 'ok' => true, + 'layout_item_id' => $layoutItemId, + 'config' => ['rooms' => $config['rooms']], + ]); + } + + if ($action === 'save-room-layout-item') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $layoutItemId = trim((string)($payload['layout_item_id'] ?? '')); + + if ($roomId === '' || $layoutItemId === '') { + api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400); + } + + $patch = [ + 'order' => array_key_exists('order', $payload) ? (int)$payload['order'] : null, + ]; + + $config = app_update_room_layout_item($config, $roomId, $layoutItemId, $patch); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'delete-room-layout-item') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $layoutItemId = trim((string)($payload['layout_item_id'] ?? '')); + + if ($roomId === '' || $layoutItemId === '') { + api_json(['ok' => false, 'error' => 'room_id and layout_item_id are required'], 400); + } + + $config = app_delete_room_layout_item($config, $roomId, $layoutItemId); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + + if ($action === 'reorder-room-grid') { + $payload = api_input(); + $roomId = trim((string)($payload['room_id'] ?? '')); + $entries = $payload['entries'] ?? []; + + if ($roomId === '' || !is_array($entries)) { + api_json(['ok' => false, 'error' => 'room_id and entries are required'], 400); + } + + $config = app_reorder_room_grid($config, $roomId, $entries); + api_json(['ok' => true, 'config' => ['rooms' => $config['rooms']]]); + } + if ($action === 'save-settings') { $payload = api_input(); if (array_key_exists('edit_mode', $payload)) { diff --git a/assets/app.css b/assets/app.css index 46564ab..0e06f43 100755 --- a/assets/app.css +++ b/assets/app.css @@ -144,7 +144,7 @@ textarea { } .content-header__back { - display: none; + display: none !important; flex: 0 0 auto; } @@ -157,6 +157,20 @@ textarea { align-items: center; gap: 10px; margin-left: 16px; + flex-wrap: wrap; + justify-content: flex-end; + margin-left: auto; +} + +.content-header__ghost-button { + min-height: 40px; + padding-inline: 12px; + border-radius: 12px; +} + +.content-header__ghost-button span { + font-size: 13px; + font-weight: 700; } .icon-button { @@ -252,6 +266,25 @@ textarea { min-height: 132px; } +.room-item.is-battery-room { + background: + radial-gradient(circle at top right, rgba(255, 193, 7, 0.12), transparent 40%), + linear-gradient(180deg, rgba(32, 28, 20, 0.94), rgba(21, 20, 18, 0.95)); + border-color: rgba(255, 193, 7, 0.18); +} + +.room-item.is-battery-room .room-item__icon { + color: #ffc94d; + --icon-node-img-filter: brightness(0) saturate(100%) invert(79%) sepia(41%) saturate(909%) hue-rotate(357deg) brightness(103%) contrast(101%); +} + +.room-item.is-battery-room.is-selected { + background: + radial-gradient(circle at top right, rgba(255, 193, 7, 0.16), transparent 40%), + linear-gradient(180deg, rgba(51, 43, 20, 0.96), rgba(24, 22, 18, 0.98)); + border-color: rgba(255, 193, 7, 0.38); +} + .room-item__icon { width: 50px; height: 50px; @@ -323,27 +356,6 @@ textarea { color: var(--text-muted); } -.room-item__status { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - gap: 5px; - flex-wrap: wrap; - margin-top: auto; -} - -.room-item__count { - min-width: 22px; - padding: 3px 6px; - border-radius: 999px; - background: rgba(255,255,255,0.04); - color: var(--text-subtle); - font-size: 10px; - font-weight: 700; - text-align: center; -} - .room-item__temp { position: absolute; top: 10px; @@ -504,6 +516,59 @@ textarea { filter: saturate(0.8); } +.grid-card--ghost { + background: transparent; + border-color: transparent; + box-shadow: none; + min-height: 120px; +} + +.grid-card--ghost .grid-card__inner { + justify-content: space-between; + gap: 10px; +} + +.grid-card--ghost .grid-card__header { + gap: 8px; +} + +.grid-card--ghost .grid-card__icon { + background: rgba(255,255,255,0.02); + border-color: rgba(255,255,255,0.05); + color: rgba(237, 240, 246, 0.42); +} + +.grid-card--ghost .grid-card__title { + font-size: 18px; +} + +.grid-card--ghost .grid-card__subtitle { + margin-top: 2px; + font-size: 13px; +} + +.grid-card--ghost .grid-card__footer--edit { + margin-top: auto; +} + +.grid-card--ghost.is-editing { + background: + radial-gradient(circle at top left, rgba(103, 214, 255, 0.08), transparent 35%), + rgba(255, 255, 255, 0.01); + border-color: rgba(103, 214, 255, 0.18); + border-style: dashed; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} + +.grid-card--ghost.is-editing .grid-card__title-line, +.grid-card--ghost.is-editing .grid-card__subtitle { + color: var(--text-muted); +} + +.grid-card--ghost.is-editing .grid-card__icon { + color: var(--accent); +} + .main-dashboard__cards .grid-card--door { transition: background 220ms ease, border-color 220ms ease, box-shadow 220ms ease, transform 220ms ease, filter 220ms ease; } @@ -775,6 +840,16 @@ textarea { min-height: 42px; } +.grid-card__layout-settings { + display: grid; + gap: 10px; + margin-top: 2px; +} + +.grid-card__layout-settings .mushroom-button { + width: 100%; +} + .mushroom-button { min-height: 48px; padding: 10px 12px; @@ -1191,6 +1266,129 @@ body.is-mobile-ui #camera-modal { flex-direction: column; } +.temperature-sensor-modal { + width: min(62vw, 760px); + max-height: 84vh; + border-radius: 30px; + border: 1px solid rgba(255,255,255,0.08); + background: linear-gradient(180deg, rgba(20, 22, 29, 0.98), rgba(12, 14, 18, 0.98)); + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.temperature-sensor-modal__header { + padding: 18px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.temperature-sensor-modal__eyebrow { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + min-height: 14px; +} + +.temperature-sensor-modal__title { + margin-top: 5px; + font-size: 18px; + font-weight: 700; +} + +.temperature-sensor-modal__body { + padding: 18px; + display: grid; + gap: 14px; + overflow: auto; +} + +.temperature-sensor-modal__current { + display: grid; + gap: 8px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); +} + +.temperature-sensor-modal__current-label { + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 11px; +} + +.temperature-sensor-modal__current-value { + font-size: 16px; + font-weight: 700; +} + +.temperature-sensor-modal__empty { + color: var(--text-muted); + padding: 20px 0; +} + +.temperature-sensor-modal__list { + display: grid; + gap: 10px; +} + +.temperature-sensor-modal__option { + width: 100%; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.04); + color: var(--text); + border-radius: 18px; + padding: 12px 14px; + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + cursor: pointer; + text-align: left; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; +} + +.temperature-sensor-modal__option:hover { + transform: translateY(-1px); + background: rgba(255,255,255,0.06); + border-color: var(--border-strong); +} + +.temperature-sensor-modal__option.is-active { + background: rgba(103, 214, 255, 0.14); + border-color: rgba(103, 214, 255, 0.28); +} + +.temperature-sensor-modal__option-main { + display: grid; + gap: 4px; + min-width: 0; +} + +.temperature-sensor-modal__option-name { + font-weight: 700; + line-height: 1.15; +} + +.temperature-sensor-modal__option-meta { + color: var(--text-muted); + font-size: 13px; + line-height: 1.2; +} + +.temperature-sensor-modal__option-value { + color: var(--accent); + font-weight: 800; + white-space: nowrap; +} + .entity-modal__header { padding: 18px 20px; border-bottom: 1px solid rgba(255,255,255,0.06); @@ -1850,6 +2048,171 @@ body.is-mobile-ui #camera-modal { color: var(--warning); } +.battery-room { + display: grid; + gap: 14px; +} + +.battery-room__header { + align-items: flex-start; +} + +.battery-room__list { + display: grid; + gap: 12px; +} + +.battery-room__empty { + grid-column: 1 / -1; +} + +.battery-card { + min-height: 96px; + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(255,255,255,0.04), transparent 34%), + linear-gradient(180deg, rgba(27, 30, 37, 0.98), rgba(18, 20, 26, 0.98)); + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.battery-card--critical, +.battery-card--empty { + border-color: rgba(255, 125, 125, 0.3); +} + +.battery-card--low { + border-color: rgba(255, 191, 84, 0.28); +} + +.battery-card--unavailable { + border-color: rgba(237, 240, 246, 0.14); +} + +.battery-card--unknown { + border-color: rgba(103, 214, 255, 0.18); +} + +.battery-card--ok { + border-color: rgba(136, 240, 199, 0.14); +} + +.battery-card__inner { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 16px 18px; +} + +.battery-card__main { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.battery-card__icon { + width: 46px; + height: 46px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.05); + color: #ffc94d; + flex: 0 0 auto; +} + +.battery-card--critical .battery-card__icon, +.battery-card--empty .battery-card__icon { + color: #ff7d7d; +} + +.battery-card--low .battery-card__icon { + color: #ffbf54; +} + +.battery-card--unavailable .battery-card__icon { + color: rgba(237, 240, 246, 0.52); +} + +.battery-card--unknown .battery-card__icon { + color: var(--accent); +} + +.battery-card--ok .battery-card__icon { + color: var(--accent-2); +} + +.battery-card__text { + min-width: 0; +} + +.battery-card__title { + font-size: 18px; + font-weight: 800; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.battery-card__source { + margin-top: 5px; + color: var(--text-muted); + font-size: 13px; +} + +.battery-card__side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + justify-content: center; + text-align: right; +} + +.battery-card__percent { + font-size: 26px; + line-height: 1; + font-weight: 800; + color: var(--text); +} + +.battery-card--critical .battery-card__percent, +.battery-card--empty .battery-card__percent { + color: #ff7d7d; +} + +.battery-card--low .battery-card__percent { + color: #ffbf54; +} + +.battery-card--unavailable .battery-card__percent, +.battery-card--unknown .battery-card__percent { + color: var(--text-muted); +} + +.battery-card--ok .battery-card__percent { + color: var(--accent-2); +} + +.battery-card__status { + color: var(--text-subtle); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.battery-card__footer { + grid-column: 1 / -1; + margin-top: -2px; + padding-left: 60px; + color: var(--text-muted); + font-size: 13px; +} + .room-entities-section__grid { grid-template-columns: repeat(5, minmax(0, 1fr)); width: 100%; @@ -1860,7 +2223,8 @@ body.is-mobile-ui #camera-modal { .room-entities-section__grid .grid-card--entity-wide, .room-entities-section__grid .grid-card--cover, .room-entities-section__grid .grid-card--climate, -.room-entities-section__grid .grid-card--auto { +.room-entities-section__grid .grid-card--auto, +.room-entities-section__grid .grid-card--ghost { grid-column: span 1; } @@ -1962,7 +2326,7 @@ body.is-mobile-ui #camera-modal { } .app-shell.is-mobile .content-header__back { - display: inline-flex; + display: inline-flex !important; } .app-shell.is-mobile .content-header__title { @@ -1986,11 +2350,6 @@ body.is-mobile-ui #camera-modal { --icon-node-img-filter: brightness(0) saturate(100%) invert(72%) sepia(45%) saturate(1190%) hue-rotate(165deg) brightness(102%) contrast(101%); } - .app-shell.is-mobile .room-item.is-selected .room-item__count { - background: rgba(255,255,255,0.04); - color: var(--text-subtle); - } - .app-shell.is-mobile .content-top { margin-top: -12px; } @@ -2034,10 +2393,15 @@ body.is-mobile-ui #camera-modal { .room-entities-section__grid .grid-card--entity-wide, .room-entities-section__grid .grid-card--cover, .room-entities-section__grid .grid-card--climate, - .room-entities-section__grid .grid-card--auto { + .room-entities-section__grid .grid-card--auto, + .room-entities-section__grid .grid-card--ghost { grid-column: span 1; } + .grid-card--ghost { + display: none; + } + .main-quick-action { min-height: 72px; } @@ -2063,6 +2427,12 @@ body.is-mobile-ui #camera-modal { border-radius: 24px; } + .temperature-sensor-modal { + width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + border-radius: 24px; + } + .entity-modal__body { padding: 16px; gap: 14px; diff --git a/assets/app.js b/assets/app.js index 180cc53..e6d3570 100755 --- a/assets/app.js +++ b/assets/app.js @@ -24,9 +24,15 @@ active: false, entityId: null, }, + temperatureSensorPopup: { + active: false, + roomId: null, + }, lastPopupSignature: '', lastEntityPopupSignature: '', + lastTemperatureSensorPopupSignature: '', roomDrag: null, + layoutItemSettingsOpen: {}, confirmResolver: null, haSocket: null, haSocketState: 'disconnected', @@ -87,6 +93,10 @@ if (nextIsMobile) { clearRoomAutoReturnTimer(); + if (state.selectedRoomId === 'batteries' && state.snapshot) { + state.selectedRoomId = 'main'; + patchSnapshotSelection('main'); + } } return nextIsMobile; @@ -422,6 +432,9 @@ function currentRoom() { const snapshot = state.snapshot || {}; + if (state.selectedRoomId === 'batteries') { + return snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || null; + } const spaces = snapshot.spaces || snapshot.rooms || []; return spaces.find((space) => space.id === state.selectedRoomId) || snapshot.selected_space || snapshot.selected_room || null; } @@ -429,6 +442,8 @@ function roomEntityCollection(snapshot, roomId) { const room = roomId === 'main' ? { entities: snapshot.main_entities || [] } + : roomId === 'batteries' + ? (snapshot.battery_room || snapshot.selected_room || snapshot.selected_space || { entities: [] }) : snapshot.space_index?.[roomId] || snapshot.space_entities?.[roomId] || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) @@ -473,6 +488,160 @@ return roomId === 'main' ? sortMainEntities(collection) : sortRoomEntities(collection); } + function roomLayoutItemCollection(snapshot, roomId) { + if (!snapshot || !roomId || roomId === 'main') { + return []; + } + + const room = snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null); + const items = Array.isArray(room?.layout_items) ? room.layout_items : []; + return items.filter((item) => item && typeof item === 'object' && String(item.type || 'ghost') === 'ghost'); + } + + function roomLayoutItems(snapshot, roomId) { + return roomLayoutItemCollection(snapshot, roomId) + .slice() + .sort((left, right) => { + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return String(left?.id || '').localeCompare(String(right?.id || ''), 'ru'); + }); + } + + function roomGridEntries(snapshot, roomId) { + const entries = []; + + roomEntities(snapshot, roomId).forEach((entity) => { + entries.push({ + kind: 'entity', + id: entity.entity_id, + order: Number(entity.order ?? 9999) || 9999, + sortLabel: String(entity.name || entity.entity_id || ''), + payload: entity, + }); + }); + + if (!isMobileViewport() && roomId !== 'main') { + roomLayoutItems(snapshot, roomId).forEach((item) => { + entries.push({ + kind: 'layout', + id: item.id, + order: Number(item.order ?? 9999) || 9999, + sortLabel: String(item.id || ''), + payload: item, + }); + }); + } + + entries.sort((left, right) => { + if (left.order !== right.order) { + return left.order - right.order; + } + if (left.kind !== right.kind) { + return left.kind === 'entity' ? -1 : 1; + } + return left.sortLabel.localeCompare(right.sortLabel, 'ru'); + }); + + return entries; + } + + function isTemperatureSensorEntity(entity) { + if (!entity || String(entity.domain || '').toLowerCase() !== 'sensor') { + return false; + } + + const entityId = String(entity.entity_id || ''); + const attributes = entity.attributes || {}; + const deviceClass = String(attributes.device_class || '').toLowerCase(); + const unit = String(attributes.unit_of_measurement || '').trim().toLowerCase(); + + return deviceClass === 'temperature' + || unit === '°c' + || unit === 'c' + || entityId.endsWith('_temperature'); + } + + function roomTemperatureSensorLabel(entity) { + const name = String(entity?.name || entity?.attributes?.friendly_name || entity?.entity_id || 'Датчик'); + const value = entity?.attributes?.current_temperature ?? entity?.attributes?.temperature ?? entity?.state ?? null; + const numeric = Number(value); + const valueText = Number.isFinite(numeric) ? `${Math.round(numeric)}°` : '—'; + + return { + name, + valueText, + meta: String(entity?.entity_id || ''), + }; + } + + function roomTemperatureSensorCandidates(snapshot, roomId) { + const room = snapshot?.space_index?.[roomId] + || (snapshot?.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot?.selected_room?.id === roomId ? snapshot.selected_room : null); + const entities = Array.isArray(room?.entities) ? room.entities : []; + const selectedId = String(room?.temperature_sensor_entity_id || '').trim(); + const candidates = entities.filter((entity) => entity && isTemperatureSensorEntity(entity)); + + candidates.sort((left, right) => { + const leftSelected = String(left.entity_id || '') === selectedId ? 0 : 1; + const rightSelected = String(right.entity_id || '') === selectedId ? 0 : 1; + if (leftSelected !== rightSelected) return leftSelected - rightSelected; + + const leftOrder = Number(left?.order ?? 9999); + const rightOrder = Number(right?.order ?? 9999); + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + + return String(left.name || left.entity_id || '').localeCompare(String(right.name || right.entity_id || ''), 'ru'); + }); + + return candidates; + } + + function roomTemperatureBadge(snapshot, room) { + const roomId = String(room?.id || ''); + if (!roomId || roomId === 'main') { + return null; + } + + const roomIndex = snapshot?.space_index?.[roomId] || room || {}; + const selectedId = String(roomIndex.temperature_sensor_entity_id || room.temperature_sensor_entity_id || '').trim(); + const entities = Array.isArray(roomIndex.entities) ? roomIndex.entities : []; + + if (selectedId) { + const selected = entities.find((entity) => entity && String(entity.entity_id || '') === selectedId); + if (selected) { + const value = selected.attributes?.current_temperature ?? selected.attributes?.temperature ?? selected.state ?? null; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return `${Math.round(numeric)}°`; + } + } + + if (room.temperature_badge) { + return room.temperature_badge; + } + } + + const firstCandidate = entities.find((entity) => entity && isTemperatureSensorEntity(entity)); + if (firstCandidate) { + const value = firstCandidate.attributes?.current_temperature ?? firstCandidate.attributes?.temperature ?? firstCandidate.state ?? null; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return `${Math.round(numeric)}°`; + } + } + + if (room.temperature_badge) { + return room.temperature_badge; + } + + return null; + } + function entityKindLabel(entity) { return String(entity?.domain || entity?.entity_id?.split('.')?.[0] || '').toLowerCase() || 'entity'; } @@ -527,6 +696,37 @@ } } + function closeTemperatureSensorPopup() { + state.temperatureSensorPopup = { + active: false, + roomId: null, + }; + state.lastTemperatureSensorPopupSignature = ''; + const backdrop = els.temperatureSensorBackdrop; + if (backdrop) { + backdrop.classList.remove('is-open'); + backdrop.setAttribute('aria-hidden', 'true'); + } + if (els.temperatureSensorBody) { + els.temperatureSensorBody.innerHTML = ''; + } + } + + function openTemperatureSensorPopup(roomId) { + const snapshot = state.snapshot || bootstrap; + const room = snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null) + || null; + if (!room || roomId === 'main' || roomId === 'batteries') return; + + state.temperatureSensorPopup = { + active: true, + roomId, + }; + renderTemperatureSensorPopup(snapshot); + } + function renderEntityPopup(snapshot) { const backdrop = els.entityBackdrop; if (!backdrop) return false; @@ -583,6 +783,116 @@ return true; } + function renderTemperatureSensorPopup(snapshot) { + const backdrop = els.temperatureSensorBackdrop; + if (!backdrop) return false; + + if (isMobileViewport() || !state.editMode) { + closeTemperatureSensorPopup(); + return false; + } + + const popupState = state.temperatureSensorPopup || {}; + const roomId = popupState.active ? popupState.roomId : null; + const room = roomId + ? (snapshot.space_index?.[roomId] + || (snapshot.selected_space?.id === roomId ? snapshot.selected_space : null) + || (snapshot.selected_room?.id === roomId ? snapshot.selected_room : null)) + : null; + + if (!popupState.active || !room || roomId === 'main' || roomId === 'batteries') { + closeTemperatureSensorPopup(); + return false; + } + + const candidates = roomTemperatureSensorCandidates(snapshot, roomId); + const selectedId = String(room.temperature_sensor_entity_id || room?.temperature_sensor_entity_id || '').trim(); + const signature = JSON.stringify([ + roomId, + selectedId, + candidates.map((entity) => `${entity.entity_id}:${entity.state}:${entity.attributes?.current_temperature ?? entity.attributes?.temperature ?? ''}`).join('|'), + state.editMode ? '1' : '0', + ]); + + if (signature === state.lastTemperatureSensorPopupSignature && backdrop.classList.contains('is-open')) { + return true; + } + + state.lastTemperatureSensorPopupSignature = signature; + backdrop.classList.add('is-open'); + backdrop.setAttribute('aria-hidden', 'false'); + + if (els.temperatureSensorTitle) { + els.temperatureSensorTitle.textContent = room.name ? `Выбрать датчик температуры · ${room.name}` : 'Выбрать датчик температуры'; + } + + if (!els.temperatureSensorBody) { + return true; + } + + els.temperatureSensorBody.replaceChildren(); + + const current = document.createElement('div'); + current.className = 'temperature-sensor-modal__current'; + const selectedEntity = selectedId + ? candidates.find((entity) => String(entity.entity_id || '') === selectedId) + : null; + current.innerHTML = ` +
Текущий выбор
+
${selectedEntity ? esc(selectedEntity.name || selectedEntity.entity_id) : 'Автоматически'}
+ `; + els.temperatureSensorBody.appendChild(current); + + const resetButton = document.createElement('button'); + resetButton.type = 'button'; + resetButton.className = `temperature-sensor-modal__option ${!selectedId ? 'is-active' : ''}`; + resetButton.innerHTML = ` + + Автоматически + Использовать первый подходящий датчик в комнате + + ${!selectedId ? 'Выбрано' : 'Сбросить'} + `; + resetButton.addEventListener('click', async () => { + await saveSpacePatch(room, { temperature_sensor_entity_id: '' }); + closeTemperatureSensorPopup(); + }); + els.temperatureSensorBody.appendChild(resetButton); + + if (!candidates.length) { + const empty = document.createElement('div'); + empty.className = 'temperature-sensor-modal__empty'; + empty.textContent = 'В этой комнате не найдено температурных датчиков.'; + els.temperatureSensorBody.appendChild(empty); + return true; + } + + const list = document.createElement('div'); + list.className = 'temperature-sensor-modal__list'; + + candidates.forEach((entity) => { + const label = roomTemperatureSensorLabel(entity); + const option = document.createElement('button'); + option.type = 'button'; + option.className = `temperature-sensor-modal__option ${String(entity.entity_id || '') === selectedId ? 'is-active' : ''}`; + option.innerHTML = ` + + ${esc(label.name)} + ${esc(label.meta)} + + ${esc(label.valueText)} + `; + option.addEventListener('click', async () => { + await saveSpacePatch(room, { temperature_sensor_entity_id: entity.entity_id }); + closeTemperatureSensorPopup(); + }); + list.appendChild(option); + }); + + els.temperatureSensorBody.appendChild(list); + return true; + } + function syncLayoutState() { if (!els.appShell) return; @@ -708,18 +1018,11 @@ if (room.id === 'main') { return updateMainEntityCard(entityId); } - - const container = els.dashboardSurface; - const existing = q(`[data-entity-id="${CSS.escape(entityId)}"]`, container); - const entity = getEntityFromSnapshot(snapshot, entityId) || getEntityDefinition(snapshot, entityId); - const shouldShow = entity && entity.visible !== false; - if (existing && !shouldShow) { - existing.remove(); + if (room.id === 'batteries') { + renderDashboardOnly(); return true; } - if (!existing || !shouldShow) return false; - const nextCard = renderEntityCard(entity); - existing.replaceWith(nextCard); + renderDashboardOnly(); return true; } @@ -974,6 +1277,10 @@ }); } + if (snapshot.battery_room?.entities) { + updateEntityInCollection(snapshot.battery_room.entities, entityId, applyPatch); + } + if (snapshot.selected_space?.entities) { updateEntityInCollection(snapshot.selected_space.entities, entityId, applyPatch); } @@ -1080,6 +1387,9 @@ if (snapshot.space_entities && typeof snapshot.space_entities === 'object') { Object.values(snapshot.space_entities).forEach((entities) => collections.push(entities)); } + if (snapshot.battery_room?.entities) { + collections.push(snapshot.battery_room.entities); + } for (const collection of collections) { if (!Array.isArray(collection)) continue; @@ -1118,6 +1428,11 @@ }); }); + if (snapshot.space_index && snapshot.space_index[roomId]) { + Object.assign(snapshot.space_index[roomId], patch); + changed = true; + } + if (snapshot.selected_space?.id === roomId) { Object.assign(snapshot.selected_space, patch); changed = true; @@ -1142,6 +1457,8 @@ visible: true, entities: snapshot.main_entities || [], } + : roomId === 'batteries' + ? snapshot.battery_room : snapshot.space_index?.[roomId] || spaces.find((space) => space.id === roomId); if (!room) return; @@ -1160,6 +1477,15 @@ return; } + if (roomId === 'batteries') { + snapshot.selected_space = { + ...room, + entities: Array.isArray(room.entities) ? room.entities : [], + }; + snapshot.selected_room = snapshot.selected_space; + return; + } + const entities = snapshot.space_index?.[roomId]?.entities || snapshot.space_entities?.[roomId] || room.entities || []; snapshot.selected_space = { ...room, @@ -1267,6 +1593,7 @@ const nextRoomId = roomId || 'main'; const token = ++state.roomSelectionToken; clearRoomAutoReturnTimer(); + closeTemperatureSensorPopup(); patchSnapshotSelection(nextRoomId); if (isMobileViewport()) { setMobileView('room'); @@ -2611,13 +2938,13 @@ upBtn.type = 'button'; upBtn.className = 'mushroom-button mushroom-button--small'; upBtn.innerHTML = ' Вверх'; - upBtn.addEventListener('click', () => saveOverridePatch(entity, { order: (entity.order ?? 9999) - 1 })); + upBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, -1)); const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'mushroom-button mushroom-button--small'; downBtn.innerHTML = ' Вниз'; - downBtn.addEventListener('click', () => saveOverridePatch(entity, { order: (entity.order ?? 9999) + 1 })); + downBtn.addEventListener('click', () => reorderRoomGridEntry(currentRoom()?.id, 'entity', entity.entity_id, 1)); actions.append(upBtn, downBtn); wrap.append(hideBtn, actions); @@ -2644,7 +2971,7 @@ } function wireRoomItemDragEvents(item, room, groupEl, hidden) { - if (!state.editMode || room.id === 'main') return; + if (!state.editMode || room.id === 'main' || room.virtual || room.id === 'batteries') return; item.draggable = true; item.addEventListener('pointerdown', (event) => { @@ -2695,7 +3022,221 @@ return card; } - function renderRoomButtons(rooms) { + function renderLayoutCard(item, room) { + const card = document.createElement('article'); + card.className = 'grid-card grid-card--ghost'; + card.dataset.layoutItemId = item.id; + card.tabIndex = state.editMode ? 0 : -1; + + if (state.editMode) { + card.classList.add('is-editing'); + } + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner grid-card__ghost-inner'; + + if (state.editMode) { + const header = document.createElement('div'); + header.className = 'grid-card__header'; + + const icon = document.createElement('div'); + icon.className = 'grid-card__icon grid-card__icon--ghost'; + icon.appendChild(createIconElement('mdi:checkbox-blank-outline')); + + const title = document.createElement('div'); + title.className = 'grid-card__title'; + title.innerHTML = 'Пустая карточка'; + + const subtitle = document.createElement('div'); + subtitle.className = 'grid-card__subtitle'; + subtitle.textContent = 'Свободное место для раскладки плиток'; + + header.append(icon, title, subtitle); + inner.appendChild(header); + inner.appendChild(renderLayoutItemEditActions(room, item)); + } + + card.appendChild(inner); + return card; + } + + function renderLayoutItemEditActions(room, item) { + const wrap = document.createElement('div'); + wrap.className = 'grid-card__footer grid-card__footer--edit'; + + const actions = document.createElement('div'); + actions.className = 'grid-card__footer-actions'; + + const isSettingsOpen = Boolean(state.layoutItemSettingsOpen?.[item.id]); + + const settingsBtn = document.createElement('button'); + settingsBtn.type = 'button'; + settingsBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + settingsBtn.innerHTML = ' Настройки'; + settingsBtn.addEventListener('click', (event) => { + event.stopPropagation(); + state.layoutItemSettingsOpen = { + ...(state.layoutItemSettingsOpen || {}), + [item.id]: !isSettingsOpen, + }; + renderDashboardOnly(); + }); + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'mushroom-button mushroom-button--small'; + upBtn.innerHTML = ' Вверх'; + upBtn.addEventListener('click', (event) => { + event.stopPropagation(); + reorderRoomGridEntry(room.id, 'layout', item.id, -1); + }); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'mushroom-button mushroom-button--small'; + downBtn.innerHTML = ' Вниз'; + downBtn.addEventListener('click', (event) => { + event.stopPropagation(); + reorderRoomGridEntry(room.id, 'layout', item.id, 1); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + deleteBtn.innerHTML = ' Удалить'; + deleteBtn.addEventListener('click', (event) => { + event.stopPropagation(); + deleteRoomLayoutItem(room.id, item.id); + }); + + actions.append(upBtn, downBtn, settingsBtn); + wrap.append(actions, deleteBtn); + + if (isSettingsOpen) { + const settings = document.createElement('div'); + settings.className = 'grid-card__layout-settings'; + + const tempBtn = document.createElement('button'); + tempBtn.type = 'button'; + tempBtn.className = 'mushroom-button mushroom-button--small mushroom-button--wide'; + tempBtn.innerHTML = ' Выбрать датчик температуры'; + tempBtn.addEventListener('click', (event) => { + event.stopPropagation(); + openTemperatureSensorPopup(room.id); + }); + + settings.appendChild(tempBtn); + wrap.appendChild(settings); + } + + return wrap; + } + + async function reorderRoomGridEntry(roomId, kind, entryId, direction) { + const nextRoomId = roomId || currentRoom()?.id || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !entryId || !direction || isMobileViewport()) { + return null; + } + + try { + const snapshot = state.snapshot || bootstrap; + const entries = roomGridEntries(snapshot, nextRoomId); + const currentIndex = entries.findIndex((entry) => entry.kind === kind && entry.id === entryId); + if (currentIndex < 0) { + return null; + } + + const targetIndex = currentIndex + direction; + if (targetIndex < 0 || targetIndex >= entries.length) { + return null; + } + + const reordered = entries.slice(); + const [moved] = reordered.splice(currentIndex, 1); + reordered.splice(targetIndex, 0, moved); + + await apiPost('reorder-room-grid', { + room_id: nextRoomId, + entries: reordered.map((entry) => ({ + kind: entry.kind, + id: entry.id, + })), + }); + + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + + return null; + } + + function renderBatteryCard(item) { + const status = String(item.battery_status || 'unknown'); + const card = document.createElement('article'); + card.className = `grid-card battery-card battery-card--${status}`; + card.dataset.entityId = item.entity_id; + card.dataset.batteryStatus = status; + + const inner = document.createElement('div'); + inner.className = 'grid-card__inner battery-card__inner'; + + const main = document.createElement('div'); + main.className = 'battery-card__main'; + + const icon = document.createElement('div'); + icon.className = 'battery-card__icon'; + icon.appendChild(createIconElement(item.battery_icon || 'mdi:battery-outline')); + + const text = document.createElement('div'); + text.className = 'battery-card__text'; + + const title = document.createElement('div'); + title.className = 'battery-card__title'; + title.textContent = item.name || item.entity_id; + + const source = document.createElement('div'); + source.className = 'battery-card__source'; + source.textContent = item.source_text || [item.source_room_name, item.source_device_name].filter(Boolean).join(' | ') || 'Без комнаты'; + + text.append(title, source); + main.append(icon, text); + + const side = document.createElement('div'); + side.className = 'battery-card__side'; + + const percent = document.createElement('div'); + percent.className = 'battery-card__percent'; + percent.textContent = item.battery_percent_text || item.battery_status_label || '—'; + + const statusLabel = document.createElement('div'); + statusLabel.className = 'battery-card__status'; + statusLabel.textContent = item.battery_status_label || 'Неизвестно'; + + side.append(percent, statusLabel); + + const footer = document.createElement('div'); + footer.className = 'battery-card__footer'; + footer.textContent = item.forecast_text + || item.forecast_reason + || (status === 'ok' ? 'Прогноз недоступен' : item.battery_status_label || ''); + const hasFooter = Boolean(footer.textContent); + + inner.append(main, side); + if (hasFooter) { + inner.appendChild(footer); + } + card.appendChild(inner); + return card; + } + + function renderRoomButtons(snapshot, rooms, batteryRoom = null) { els.roomList.innerHTML = ''; const sortedRooms = [...(rooms || [])].sort((left, right) => { if (left.id === 'main') return -1; @@ -2725,7 +3266,7 @@ const renderItem = (room, hidden = false) => { const item = document.createElement('div'); - item.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${hidden ? 'is-hidden-room' : ''} ${state.editMode ? 'is-editing' : ''}`; + item.className = `room-item ${room.id === state.selectedRoomId ? 'is-selected' : ''} ${room.id === 'main' ? 'is-main' : ''} ${room.virtual ? 'is-virtual is-battery-room' : ''} ${hidden ? 'is-hidden-room' : ''} ${state.editMode && !room.virtual ? 'is-editing' : ''}`; item.dataset.roomId = room.id; item.dataset.roomGroup = hidden ? 'hidden' : 'visible'; item.tabIndex = 0; @@ -2755,9 +3296,13 @@ const body = document.createElement('div'); body.className = 'room-item__body'; - const activeCount = Number(room.active_entity_count ?? room.entity_count ?? 0) || 0; + const activeCount = room.id === 'batteries' + ? Number(room.problem_count ?? room.active_entity_count ?? room.entity_count ?? 0) || 0 + : Number(room.active_entity_count ?? room.entity_count ?? 0) || 0; const metaText = room.id === 'main' ? 'Главный экран' + : room.id === 'batteries' + ? (room.battery_summary_text || `${room.entity_count || 0} батареек`) : activeCount > 0 ? `${activeCount} ${pluralizeActiveEntities(activeCount)}` : 'Нет активных'; @@ -2767,24 +3312,17 @@ `; content.append(icon, body); - if (room.temperature_badge) { + const tempBadge = roomTemperatureBadge(snapshot, room); + if (tempBadge) { item.classList.add('has-temp'); const temp = document.createElement('div'); temp.className = 'room-item__temp'; - temp.textContent = room.temperature_badge; + temp.textContent = tempBadge; item.appendChild(temp); } - const status = document.createElement('div'); - status.className = 'room-item__status'; - - const count = document.createElement('div'); - count.className = 'room-item__count'; - count.textContent = `${activeCount}`; - status.appendChild(count); - - item.append(content, status); - if (state.editMode && room.id !== 'main') { + item.append(content); + if (state.editMode && room.id !== 'main' && !room.virtual && room.id !== 'batteries') { item.appendChild(renderRoomEditActions(room)); } wireRoomItemDragEvents(item, room, hidden ? hiddenGroup : visibleGroup, hidden); @@ -2795,6 +3333,10 @@ visibleGroup.appendChild(renderItem(room, false)); }); + if (batteryRoom && !isMobileViewport()) { + visibleGroup.appendChild(renderItem(batteryRoom, false)); + } + els.roomList.appendChild(visibleGroup); if (state.editMode && hiddenRooms.length) { @@ -2826,12 +3368,36 @@ els.contentHeader.classList.toggle('hidden', room.id === 'main' && !isMobileRoomView()); } updateMainPrintStrip(snapshot); + if (room.id === 'batteries') { + els.selectedRoomEyebrow.textContent = 'Псевдо-комната'; + els.selectedRoomTitle.textContent = room.name || 'Батарейки'; + const total = Number(room.entity_count ?? 0) || 0; + const critical = Number(room.problem_count ?? room.active_entity_count ?? 0) || 0; + const unavailable = Number(room.unavailable_count ?? 0) || 0; + const unknown = Number(room.unknown_count ?? 0) || 0; + const summaryParts = []; + if (critical > 0) { + summaryParts.push(`${critical} ${pluralizeRu(critical, 'проблемная', 'проблемных', 'проблемных')}`); + } + if (unavailable > 0) { + summaryParts.push(`${unavailable} ${pluralizeRu(unavailable, 'недоступная', 'недоступных', 'недоступных')}`); + } + if (unknown > 0) { + summaryParts.push(`${unknown} ${pluralizeRu(unknown, 'неизвестная', 'неизвестных', 'неизвестных')}`); + } + els.selectedRoomMeta.textContent = summaryParts.length + ? `${summaryParts.join(' · ')} · ${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}` + : `${total} ${pluralizeRu(total, 'батарейка', 'батарейки', 'батареек')}`; + renderSelectedRoomActions(snapshot); + return; + } if (room.id !== 'main') { els.selectedRoomEyebrow.textContent = 'Пространство'; els.selectedRoomTitle.textContent = room.name || 'Панель'; const entities = roomEntities(snapshot, room.id || 'main'); const activeCount = Number(room.active_entity_count ?? entities.length) || 0; els.selectedRoomMeta.textContent = `${activeCount} ${pluralizeActiveEntities(activeCount)}`; + renderSelectedRoomActions(snapshot); return; } @@ -2839,6 +3405,36 @@ els.selectedRoomEyebrow.textContent = ''; els.selectedRoomTitle.textContent = room.name || 'Панель'; els.selectedRoomMeta.textContent = `${entities.length} ${pluralizeIncludedEntities(entities.length)}`; + renderSelectedRoomActions(snapshot); + } + + function renderSelectedRoomActions(snapshot) { + if (!els.selectedRoomActions) return; + + const room = snapshot.selected_space || snapshot.selected_room || {}; + els.selectedRoomActions.replaceChildren(); + + if (isMobileViewport() || !state.editMode || room.id === 'main' || room.id === 'batteries') { + return; + } + + const addButton = document.createElement('button'); + addButton.type = 'button'; + addButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; + addButton.innerHTML = 'Пустая карточка'; + addButton.addEventListener('click', () => { + createRoomLayoutItem(room.id); + }); + + const temperatureButton = document.createElement('button'); + temperatureButton.type = 'button'; + temperatureButton.className = 'mushroom-button mushroom-button--small content-header__ghost-button'; + temperatureButton.innerHTML = 'Выбрать датчик температуры'; + temperatureButton.addEventListener('click', () => { + openTemperatureSensorPopup(room.id); + }); + + els.selectedRoomActions.append(addButton, temperatureButton); } function renderDashboard(snapshot) { @@ -2865,7 +3461,40 @@ return; } - const roomEntitiesList = roomEntities(snapshot, room.id); + if (room.id === 'batteries') { + const section = document.createElement('section'); + section.className = 'room-entities-section battery-room'; + + const header = document.createElement('div'); + header.className = 'room-entities-section__header battery-room__header'; + + const title = document.createElement('div'); + title.className = 'room-entities-section__title'; + title.textContent = room.battery_summary_text || `${Number(room.entity_count ?? 0) || 0} батареек`; + header.appendChild(title); + section.appendChild(header); + + const list = document.createElement('div'); + list.className = 'battery-room__list'; + const items = Array.isArray(room.entities) ? room.entities : []; + + items.forEach((item) => { + list.appendChild(renderBatteryCard(item)); + }); + + if (!items.length) { + const empty = document.createElement('article'); + empty.className = 'loading-card battery-room__empty'; + empty.textContent = 'Батарейки с ярлыком «Батарейка» не найдены.'; + list.appendChild(empty); + } + + section.appendChild(list); + grid.appendChild(section); + return; + } + + const visibleEntries = roomGridEntries(snapshot, room.id); const hiddenEntitiesList = state.editMode && room.id !== 'main' ? roomEntitiesIncludingHidden(snapshot, room.id).filter((entity) => entity.visible === false) : []; @@ -2879,18 +3508,22 @@ const visibleTitle = document.createElement('div'); visibleTitle.className = 'room-entities-section__title'; - visibleTitle.textContent = room.id === 'main' ? 'Объекты' : 'Объекты'; + visibleTitle.textContent = 'Объекты'; visibleHeader.appendChild(visibleTitle); visibleSection.appendChild(visibleHeader); } const visibleGrid = document.createElement('div'); visibleGrid.className = 'grid-surface room-entities-section__grid'; - roomEntitiesList.forEach((entity) => { - visibleGrid.appendChild(renderEntityCard(entity)); + visibleEntries.forEach((entry) => { + if (entry.kind === 'layout') { + visibleGrid.appendChild(renderLayoutCard(entry.payload, room)); + } else { + visibleGrid.appendChild(renderEntityCard(entry.payload)); + } }); - if (!roomEntitiesList.length) { + if (!visibleEntries.length) { const empty = document.createElement('article'); empty.className = 'loading-card grid-card--full'; empty.textContent = 'В этой комнате нет доступных объектов.'; @@ -3179,11 +3812,12 @@ } syncLayoutState(); - renderRoomButtons(snapshot.spaces || snapshot.rooms); + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); renderSelectedRoom(snapshot); renderDashboard(snapshot); renderPopup(snapshot); renderEntityPopup(snapshot); + renderTemperatureSensorPopup(snapshot); const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; @@ -3199,6 +3833,7 @@ renderDashboard(snapshot); renderPopup(snapshot); renderEntityPopup(snapshot); + renderTemperatureSensorPopup(snapshot); } function refreshCurrentRoomLayout(entityId) { @@ -3210,6 +3845,11 @@ return; } + if (room.id === 'batteries') { + renderDashboardOnly(); + return; + } + renderDashboardOnly(); } @@ -3237,7 +3877,7 @@ function renderSidebarOnly() { const snapshot = state.snapshot || bootstrap; if (!snapshot || !(snapshot.spaces || snapshot.rooms)) return; - renderRoomButtons(snapshot.spaces || snapshot.rooms); + renderRoomButtons(snapshot, snapshot.spaces || snapshot.rooms, snapshot.battery_room); const roomCount = Math.max(0, (snapshot.spaces?.length || snapshot.rooms?.length || 1) - 1); els.roomsCount.textContent = state.editMode ? `${roomCount} ${pluralizeRooms(roomCount)}` : ''; els.editModeToggle.classList.toggle('is-active', state.editMode); @@ -3347,16 +3987,20 @@ async function saveSpacePatch(room, patch) { try { + const nextPatch = {}; + if (patch.visible !== undefined) nextPatch.visible = Boolean(patch.visible); + if (patch.order !== undefined) nextPatch.order = patch.order; + if (patch.name !== undefined) nextPatch.name = patch.name; + if (patch.icon !== undefined) nextPatch.icon = patch.icon; + if (patch.temperature_sensor_entity_id !== undefined) { + nextPatch.temperature_sensor_entity_id = String(patch.temperature_sensor_entity_id || ''); + } + await apiPost('save-space-override', { room_id: room.id, ...patch, }); - patchSnapshotSpace(room.id, { - visible: patch.visible !== undefined ? Boolean(patch.visible) : undefined, - order: patch.order !== undefined ? patch.order : undefined, - name: patch.name !== undefined ? patch.name : undefined, - icon: patch.icon !== undefined ? patch.icon : undefined, - }); + patchSnapshotSpace(room.id, nextPatch); try { await loadSnapshot(state.selectedRoomId || room.id || 'main'); } catch (reloadError) { @@ -3369,6 +4013,78 @@ } } + async function createRoomLayoutItem(roomId) { + const room = currentRoom(); + const nextRoomId = roomId || room?.id || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || isMobileViewport()) { + return null; + } + + try { + const snapshot = state.snapshot || bootstrap; + const items = roomGridEntries(snapshot, nextRoomId); + const maxOrder = items.reduce((max, item) => Math.max(max, Number(item.order ?? 9999) || 9999), 0); + await apiPost('create-room-layout-item', { + room_id: nextRoomId, + order: maxOrder + 10, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + + return null; + } + + async function saveRoomLayoutItem(roomId, layoutItemId, patch) { + const nextRoomId = roomId || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return; + + try { + await apiPost('save-room-layout-item', { + room_id: nextRoomId, + layout_item_id: layoutItemId, + ...patch, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + + async function deleteRoomLayoutItem(roomId, layoutItemId) { + const nextRoomId = roomId || state.selectedRoomId || 'main'; + if (!nextRoomId || nextRoomId === 'main' || !layoutItemId) return; + + try { + await apiPost('delete-room-layout-item', { + room_id: nextRoomId, + layout_item_id: layoutItemId, + }); + try { + await loadSnapshot(state.selectedRoomId || nextRoomId || 'main'); + } catch (reloadError) { + console.warn(reloadError); + } + render(); + } catch (error) { + console.error(error); + setStatus('Ошибка сохранения', 'error'); + } + } + function wireEvents() { els.selectedRoomBack?.addEventListener('click', () => { if (!isMobileViewport()) return; @@ -3420,6 +4136,20 @@ closeEntityPopup(); }); + els.temperatureSensorBackdrop?.addEventListener('click', (event) => { + if (event.target === els.temperatureSensorBackdrop) { + closeTemperatureSensorPopup(); + } + }); + + els.temperatureSensorModalPanel?.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + els.temperatureSensorClose?.addEventListener('click', () => { + closeTemperatureSensorPopup(); + }); + els.popupDebugButton?.addEventListener('click', () => { showDebugPopup(); }); @@ -3460,6 +4190,7 @@ els.contentTop = q('.content-top'); els.mainPrintStripSlot = $('main-print-strip-slot'); els.contentHeader = q('.content-header'); + els.selectedRoomActions = $('selected-room-actions'); els.selectedRoomEyebrow = $('selected-room-eyebrow'); els.selectedRoomTitle = $('selected-room-title'); els.selectedRoomMeta = $('selected-room-meta'); @@ -3483,6 +4214,11 @@ els.entityEyebrow = $('entity-modal-eyebrow'); els.entityTitle = $('entity-modal-title'); els.entityBody = $('entity-modal-body'); + els.temperatureSensorBackdrop = $('temperature-sensor-modal'); + els.temperatureSensorModalPanel = $('temperature-sensor-modal-panel'); + els.temperatureSensorClose = $('temperature-sensor-modal-close'); + els.temperatureSensorTitle = $('temperature-sensor-modal-title'); + els.temperatureSensorBody = $('temperature-sensor-modal-body'); if (els.cameraPoster && !els.cameraPoster.dataset.boundErrorHandler) { els.cameraPoster.dataset.boundErrorHandler = '1'; @@ -3584,6 +4320,7 @@ } const affectsWeather = snapshot.weather?.entity_id === entityId || entityId === 'sensor.weather_temperature'; const affectsRoom = currentRoomId !== 'main' && existingEntity !== null; + const affectsTemperatureBadge = isTemperatureSensorEntity(entityRecord); if (entityRecord && !statePayloadChanged(entityRecord, newState)) { const triggerEntities = popupTriggerEntities(); @@ -3634,9 +4371,15 @@ updateMainEntityCard(entityId); } renderSelectedRoom(snapshot); + renderSidebarOnly(); } else if (affectsRoom) { updateRoomEntityCard(entityId); renderSelectedRoom(snapshot); + renderSidebarOnly(); + } + + if (affectsTemperatureBadge) { + renderSidebarOnly(); } if (state.entityPopup?.active) { diff --git a/config/config.json b/config/config.json index 2794586..4934332 100755 --- a/config/config.json +++ b/config/config.json @@ -83,9 +83,7 @@ "poster_url": "http://10.0.6.110:1984/api/frame.jpeg?src=doorbell_main", "popup_timeout_minutes": 3, "trigger_entities": [ - "binary_sensor.doorbell_person_occupancy", - "binary_sensor.barn_all_occupancy", - "binary_sensor.doorbell_all_occupancy" + "binary_sensor.doorbell_person_occupancy" ] }, "rooms": [ @@ -100,7 +98,7 @@ "visible": false }, "switch.0xc02cedfffef131dc": { - "order": 9999 + "order": 10060 }, "sensor.garage_light_illuminance": { "order": 10000, @@ -165,9 +163,36 @@ }, "sensor.0x70ac08fffeadbe42_moving": { "visible": false + }, + "cover.garazh_vorota": { + "order": 10000 + }, + "switch.garage_switch": { + "order": 10010 + }, + "switch.garazh_zariadka": { + "order": 10050 } }, - "order": 8 + "order": 8, + "layout_items": [ + { + "id": "slot_69bc55ef2be6c903573086", + "type": "ghost", + "order": 10020 + }, + { + "id": "slot_69bc55f02ec5d464636840", + "type": "ghost", + "order": 10030 + }, + { + "id": "slot_69bc55fd89791387446255", + "type": "ghost", + "order": 10040 + } + ], + "temperature_sensor_entity_id": "sensor.garazh_zariadka_device_temperature" }, { "id": "sarai", @@ -185,7 +210,8 @@ "entity_ids": [], "entity_overrides": { "remote.detskaia": { - "visible": true + "visible": true, + "order": 10010 }, "update.0x54ef441000d3a2ae": { "visible": false @@ -204,8 +230,12 @@ }, "media_player.detskaia": { "visible": false + }, + "light.detskaia_svet": { + "order": 10000 } - } + }, + "layout_items": [] }, { "id": "kamery", @@ -315,8 +345,61 @@ }, "update.0x8c8b48fffed1ccf1": { "visible": false + }, + "switch.0x8c8b48fffed1ccf1": { + "order": 10000 + }, + "switch.terrasa_switch_right": { + "order": 10010 + }, + "light.terrasa_girlianda_i_svet_l2": { + "order": 10020 + }, + "light.0x3425b4fffe367dd7": { + "order": 10030 + }, + "light.terrasa_girlianda_i_svet_l1": { + "order": 10060 + }, + "cover.0xa4c1388b95d8c6fd": { + "order": 10130 + }, + "switch.terrasa_switch_left": { + "order": 10050 + }, + "cover.0xa4c1382b4b27fe39": { + "order": 10100 + }, + "cover.0xa4c13896938267e3": { + "order": 10110 + }, + "cover.0xa4c138667b24fd65": { + "order": 10120 } - } + }, + "layout_items": [ + { + "id": "slot_69bc51060b6fe832199450", + "type": "ghost", + "order": 10040 + }, + { + "id": "slot_69bc55bfb6770947221860", + "type": "ghost", + "order": 10070 + }, + { + "id": "slot_69bc55cab20df172721660", + "type": "ghost", + "order": 10080 + }, + { + "id": "slot_69bc55cbc357c393673482", + "type": "ghost", + "order": 10090 + } + ], + "temperature_sensor_entity_id": "sensor.terrasa_temperatura_temperature" }, { "id": "ulitsa", @@ -618,8 +701,58 @@ }, "switch.cambarn_push_notifications": { "visible": false + }, + "switch.0x08b95ffffeb5171c": { + "order": 10028 + }, + "light.sarai_svet": { + "order": 10068 + }, + "switch.0x54ef4410004ae163_right": { + "order": 10000 + }, + "switch.relayswitch": { + "order": 10000 } - } + }, + "layout_items": [ + { + "id": "slot_69bc51604cd9a655530009", + "type": "ghost", + "order": 10006 + }, + { + "id": "slot_69bc51618576e557534578", + "type": "ghost", + "order": 10017 + }, + { + "id": "slot_69bc5173bcc31597082929", + "type": "ghost", + "order": 10027 + }, + { + "id": "slot_69bc51a07b397758519172", + "type": "ghost", + "order": 10037 + }, + { + "id": "slot_69bc51a18cd65501054136", + "type": "ghost", + "order": 10047 + }, + { + "id": "slot_69bc51a26a869826830118", + "type": "ghost", + "order": 10057 + }, + { + "id": "slot_69bc51a537f7a717770602", + "type": "ghost", + "order": 10067 + } + ], + "temperature_sensor_entity_id": "sensor.weather_temperature" }, { "id": "c7829e2bba524bc5aeb2662866058b57", @@ -672,8 +805,51 @@ }, "media_player.televizor_v_gostinoi": { "visible": false + }, + "light.kukhnia_svet_right": { + "order": 10020 + }, + "light.kukhnia_svet_center": { + "order": 10010 + }, + "light.kukhnia_svet_left": { + "order": 10000 + }, + "climate.air_conditioner_hsu_07hrm203_r3_in": { + "order": 10050 + }, + "remote.mitv_afmu0": { + "order": 10060 } - } + }, + "layout_items": [ + { + "id": "slot_69bc558692ea6091392089", + "type": "ghost", + "order": 10030 + }, + { + "id": "slot_69bc559079a55766958575", + "type": "ghost", + "order": 10040 + }, + { + "id": "slot_69bc55918cf6f412189279", + "type": "ghost", + "order": 10070 + }, + { + "id": "slot_69bc5592a0e1e292667710", + "type": "ghost", + "order": 10080 + }, + { + "id": "slot_69bc5593e7ac9622553048", + "type": "ghost", + "order": 10090 + } + ], + "temperature_sensor_entity_id": "sensor.kukhnia_temperatura_temperature" }, { "id": "prikhozhaia", @@ -719,9 +895,38 @@ "visible": false }, "binary_sensor.reolink_silent_mode_active": { - "visible": true + "visible": true, + "order": 10060 + }, + "light.hall_switch": { + "order": 10000 + }, + "script.toggle_reolink_silent": { + "order": 10050 } - } + }, + "layout_items": [ + { + "id": "slot_69bc55a58f40a012297459", + "type": "ghost", + "order": 10010 + }, + { + "id": "slot_69bc55a68fa36878820522", + "type": "ghost", + "order": 10020 + }, + { + "id": "slot_69bc55a81f56c544279655", + "type": "ghost", + "order": 10030 + }, + { + "id": "slot_69bc55a9373e0445635380", + "type": "ghost", + "order": 10040 + } + ] }, { "id": "bf6b7f2827294fcea198262d5bf0a1b3", @@ -805,8 +1010,58 @@ }, "sensor.0xa4c138fe1cdd2a21_temperature": { "visible": false + }, + "light.roditeli_svet_left": { + "order": 10000 + }, + "light.roditeli_svet_right": { + "order": 10010 + }, + "remote.shield": { + "order": 10100 + }, + "switch.smallroom_switch": { + "order": 10050 } - } + }, + "layout_items": [ + { + "id": "slot_69bc555e1ad1c065023546", + "type": "ghost", + "order": 10020 + }, + { + "id": "slot_69bc55653ed9a111710448", + "type": "ghost", + "order": 10030 + }, + { + "id": "slot_69bc55703bf74010237960", + "type": "ghost", + "order": 10040 + }, + { + "id": "slot_69bc55713c237771552554", + "type": "ghost", + "order": 10060 + }, + { + "id": "slot_69bc55722858c536911665", + "type": "ghost", + "order": 10070 + }, + { + "id": "slot_69bc5573152eb561990860", + "type": "ghost", + "order": 10080 + }, + { + "id": "slot_69bc5574182c3344138189", + "type": "ghost", + "order": 10090 + } + ], + "temperature_sensor_entity_id": "sensor.0xa4c138fe1cdd2a21_temperature" }, { "id": "79c23414b5b840e1b83b09c0d970dcf3", @@ -1046,7 +1301,9 @@ "select.uvlazhnitel_led_brightness": { "visible": false } - } + }, + "temperature_sensor_entity_id": "sensor.spalnya_temp_temperature", + "layout_items": [] }, { "id": "chulan", diff --git a/index.php b/index.php index ccfefe7..d44f22b 100755 --- a/index.php +++ b/index.php @@ -23,8 +23,8 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'), - - + +
@@ -53,7 +53,7 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'),
-
@@ -61,6 +61,7 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'),

Загрузка

+
@@ -107,6 +108,21 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'), + +