auto-sync: 2026-04-15 15:00:01

This commit is contained in:
Stream
2026-04-15 15:00:01 +03:00
parent 0b01124af4
commit b6bcca6278

View File

@@ -0,0 +1,270 @@
title: Доступность устройств
views:
- title: Доступность устройств
path: availability
cards:
# ═══ Переключатель периода ═══
- type: entities
entities:
- entity: input_select.avail_period
name: Период
card_mod:
style: |
ha-card {
padding: 8px 16px !important;
margin-bottom: 4px !important;
}
# ═══ Кнопка обновить + статус ═══
- type: entities
entities:
- entity: sensor.avail_calc_progress
name: Статус расчёта
- type: button
name: Принудительный пересчёт
action_name: Обновить
tap_action:
action: call-service
service: input_select.select_option
target:
entity_id: input_select.avail_period
data:
option: "7d"
card_mod:
style: |
ha-card {
padding: 8px 16px !important;
margin-bottom: 4px !important;
}
# ═══ Сводная карточка ═══
- type: custom:button-card
entity: sensor.avail_calc_progress
show_name: false
show_state: false
show_icon: false
custom_fields:
card: |
[[[
const devs = Object.entries(hass.states)
.filter(([k]) => k.startsWith('sensor.avail_') && !k.startsWith('sensor.avail_area_') && !k.startsWith('sensor.avail_calc_'))
.map(([k,v]) => ({ id: k, pct: parseFloat(v.state), attrs: v.attributes }));
if (!devs.length) return '<div style="padding:16px;color:var(--secondary-text-color);">Нет данных</div>';
const avg = (devs.reduce((s,d) => s + d.pct, 0) / devs.length).toFixed(1);
const problems = devs.filter(d => d.pct < 95).sort((a,b) => a.pct - b.pct);
const worse = devs.filter(d => d.attrs.trend === 'down').length;
const better = devs.filter(d => d.attrs.trend === 'up').length;
const avgClr = avg < 90 ? 'var(--error-color)' : avg < 95 ? 'var(--warning-color)' : avg < 99 ? '#f0c040' : 'var(--success-color)';
let h = `<div style="padding:16px;">`;
// Средний %
h += `<div style="font-size:13px;color:var(--secondary-text-color);margin-bottom:6px;">Средняя доступность</div>`;
h += `<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;">`;
h += `<div style="flex:1;position:relative;height:20px;background:var(--divider-color);border-radius:10px;overflow:hidden;">`;
h += `<div style="width:${avg}%;height:100%;background:${avgClr};border-radius:10px;transition:width .5s;"></div>`;
h += `</div>`;
h += `<div style="font-size:26px;font-weight:700;color:${avgClr};min-width:70px;text-align:right;">${avg}%</div>`;
h += `</div>`;
// Проблемные
if (problems.length) {
h += `<div style="font-size:13px;font-weight:600;color:var(--error-color);margin-bottom:6px;">🔴 Проблемных (&lt;95%): ${problems.length}</div>`;
problems.forEach(p => {
const ic = {switch:'🔌',light:'💡',binary_sensor:'🔘',sensor:'🌡️',climate:'❄️',cover:'🪟',lock:'🔒',media_player:'📺',fan:'🌀',vacuum:'🤖',device_tracker:'📍',humidifier:'💧',water_heater:'🚿',siren:'🚨',button:'🔔'}[p.attrs.entity_id?.split('.')[0]] || '📟';
const pc = p.pct < 90 ? 'var(--error-color)' : 'var(--warning-color)';
const ti = p.attrs.trend === 'down' ? '📉' : p.attrs.trend === 'up' ? '📈' : '➡️';
h += `<div style="display:flex;align-items:center;gap:6px;padding:2px 0;font-size:13px;">`;
h += `<span>${ic}</span><span style="flex:1;">${p.attrs.friendly_name || p.id}</span>`;
h += `<span style="color:${pc};font-weight:600;">${p.pct}%</span><span>${ti}</span>`;
h += `</div>`;
});
}
// Тренды
h += `<div style="display:flex;gap:16px;margin-top:10px;font-size:12px;color:var(--secondary-text-color);">`;
h += `<span>📉 Хуже: ${worse}</span><span>📈 Лучше: ${better}</span>`;
h += `</div>`;
h += `</div>`;
return h;
]]]
styles:
card:
- padding: 0
- background: var(--card-background-color)
custom_fields:
card:
- padding: 0
- margin: 0
# ═══ Группы по комнатам ═══
# Каждая комната — отдельная collapsible секция с auto-entities
# Фильтр по атрибуту area. Сортировка: проблемные комнаты первые.
- type: custom:auto-entities
card:
type: vertical-stack
filter:
include:
- entity_id: "sensor.avail_area_*"
exclude: []
sort:
method: state
numeric: true
card_param: cards
card_template:
type: conditional
conditions: &area_cond
- entity: "[[entity.entity_id]]"
state_not: "0" # always show
row: {} # placeholder
# We can't easily do nested auto-entities per area in Lovelace.
# Instead, render each area as a section header + its devices.
# ═══ Альтернативный подход: все устройства одной auto-entities ═══
# Группировка по area — визуальная через JavaScript в button-card
- type: custom:auto-entities
card:
type: vertical-stack
filter:
include:
- entity_id: "sensor.avail_area_*"
options:
type: custom:button-card
entity: "[[entity.entity_id]]"
show_name: false
show_state: false
show_icon: false
tap_action:
action: none
custom_fields:
area: |
[[[
const pct = parseFloat(entity.state);
const a = entity.attributes;
const clr = pct < 90 ? 'var(--error-color)' : pct < 95 ? 'var(--warning-color)' : pct < 99 ? '#f0c040' : 'var(--success-color)';
const iconMap = {'спальня':'🛏️','кухня':'🍳','ванная':'🚿','гостиная':'🛋️','прихожая':'🚪','дом':'🏠','улица':'🌳','гараж':'🚗','подвал':'🏚️','балкон':'🌅','кабинет':'💼','детская':'🧸'};
const name = a.friendly_name || '';
const ic = iconMap[name.toLowerCase()] || '📍';
let h = `<div style="display:flex;align-items:center;gap:10px;padding:10px 0 4px 0;">`;
h += `<span style="font-size:18px;">${ic}</span>`;
h += `<span style="flex:1;font-weight:600;font-size:15px;">${name}</span>`;
h += `<span style="color:${clr};font-weight:700;font-size:16px;">${pct}%</span>`;
h += `<span style="font-size:12px;color:var(--secondary-text-color);">${a.device_count||'?'} устр.</span>`;
if (a.problem_count > 0) {
h += `<span style="background:var(--error-color);color:#fff;border-radius:10px;padding:1px 8px;font-size:11px;font-weight:600;">${a.problem_count}</span>`;
}
h += `</div>`;
h += `<div style="height:1px;background:var(--divider-color);margin-bottom:4px;"></div>`;
return h;
]]]
styles:
card:
- padding: 4px 16px
- background: transparent
- box-shadow: none
custom_fields:
area:
- padding: 0
- entity_id: "sensor.avail_*"
options:
type: custom:button-card
entity: "[[entity.entity_id]]"
show_name: false
show_state: false
show_icon: false
tap_action:
action: more-info
custom_fields:
dev: |
[[[
const pct = parseFloat(entity.state);
const a = entity.attributes;
const clr = pct < 90 ? 'var(--error-color)' : pct < 95 ? 'var(--warning-color)' : pct < 99 ? '#f0c040' : 'var(--success-color)';
const ic = {switch:'🔌',light:'💡',binary_sensor:'🔘',sensor:'🌡️',climate:'❄️',cover:'🪟',lock:'🔒',media_player:'📺',fan:'🌀',vacuum:'🤖',device_tracker:'📍',humidifier:'💧',water_heater:'🚿',siren:'🚨',button:'🔔'}[a.entity_id?.split('.')[0]] || '📟';
const ti = a.trend === 'down' ? '📉' : a.trend === 'up' ? '📈' : '➡️';
let downInfo = '';
if (a.down_count > 0) {
const mh = Math.floor((a.max_downtime_minutes||0)/60);
const mm = (a.max_downtime_minutes||0) % 60;
downInfo = `${a.down_count} пад., макс ${mh > 0 ? mh+'ч ':''}${mm}мин`;
}
// Sparkline SVG
let spark = '';
if (a.sparkline && a.sparkline.length > 1) {
const pts = a.sparkline;
const mn = Math.min(...pts), mx = Math.max(...pts), rng = mx-mn||1;
const w=120, h=20;
const svgPts = pts.map((v,i) => `${(i/(pts.length-1))*w},${h-((v-mn)/rng)*h}`).join(' ');
spark = `<svg viewBox="0 0 ${w} ${h}" class="spark-d" style="width:120px;height:20px;display:block;"><polyline points="${svgPts}" fill="none" stroke="${clr}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
// Desktop
let o = `<div class="av-desk">`;
o += `<div style="display:flex;align-items:center;gap:8px;">`;
o += `<span style="font-size:16px;">${ic}</span>`;
o += `<span style="flex:1;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${a.friendly_name||a.entity_id}</span>`;
o += `<span style="color:${clr};font-weight:700;font-size:15px;min-width:50px;text-align:right;">${pct}%</span>`;
o += `<span style="font-size:11px;">${ti}</span>`;
o += `</div>`;
o += `<div style="display:flex;align-items:center;gap:8px;margin-top:3px;">`;
o += `<div style="flex:1;height:6px;background:var(--divider-color);border-radius:3px;overflow:hidden;">`;
o += `<div style="width:${pct}%;height:100%;background:${clr};border-radius:3px;transition:width .5s;"></div>`;
o += `</div>`;
o += `</div>`;
if (downInfo || spark) {
o += `<div style="display:flex;align-items:center;justify-content:space-between;margin-top:2px;">`;
o += `<span style="font-size:11px;color:var(--secondary-text-color);">${downInfo}</span>`;
o += spark;
o += `</div>`;
}
o += `</div>`;
// Mobile
o += `<div class="av-mob" style="display:none;">`;
o += `<div style="display:flex;align-items:center;gap:6px;">`;
o += `<span style="font-size:15px;">${ic}</span>`;
o += `<span style="flex:1;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${a.friendly_name||a.entity_id}</span>`;
o += `<span style="color:${clr};font-weight:700;font-size:15px;">${pct}%</span>`;
o += `</div>`;
o += `</div>`;
return o;
]]]
styles:
card:
- padding: 6px 16px
- background: transparent
- box-shadow: none
custom_fields:
dev:
- padding: 0
card_mod:
style: |
ha-card { padding: 6px 16px !important; box-shadow: none !important; background: transparent !important; }
.spark-d { display: block; }
.av-desk { display: block; }
.av-mob { display: none !important; }
@media (max-width: 768px) {
.spark-d { display: none !important; }
.av-desk { display: none !important; }
.av-mob { display: block !important; }
}
exclude:
- entity_id: "sensor.avail_calc_*"
sort:
method: attribute
attribute: area
secondary_sort:
method: state
numeric: true
reverse: true
card:
type: vertical-stack
show_empty: true