Files
wiki/tasks/ha-availability-dashboard/dashboard/availability.yaml
2026-04-15 15:00:01 +03:00

271 lines
14 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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