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'),
@@ -107,6 +108,21 @@ $appTitle = htmlspecialchars((string)($config['app']['title'] ?? 'Wall Panel'),
+
+