auto-sync: 2026-04-15 15:00:01
This commit is contained in:
270
tasks/ha-availability-dashboard/dashboard/availability.yaml
Normal file
270
tasks/ha-availability-dashboard/dashboard/availability.yaml
Normal 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;">🔴 Проблемных (<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
|
||||
Reference in New Issue
Block a user