224 files

This commit is contained in:
Home Assistant Version Control
2026-07-27 13:30:34 +00:00
parent b5723aa856
commit e36b8a1a22
224 changed files with 9967 additions and 2663 deletions
@@ -969,8 +969,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
into a single nested ``schedule`` object. Behaviour is identical — readers
accept both shapes — this just makes storage canonical and unblocks the
calendar recurrence kinds (nth-weekday etc.).
minor_version 3 → 4 (discussion #49): seed ``responsible_user_id`` on
rotation tasks that were configured without an initial assignee — those
were invisible to every user filter until their first completion.
"""
if entry.version > 1 or entry.minor_version >= 3:
if entry.version > 1 or entry.minor_version >= 4:
return True
is_object_entry = entry.unique_id != GLOBAL_UNIQUE_ID
@@ -1014,6 +1018,21 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
data[CONF_TASKS] = {task_id: normalize_task_storage(td) for task_id, td in tasks_data.items()}
minor = 3
# 3 → 4: rotation tasks without an initial assignee get one (discussion #49)
if minor < 4:
if is_object_entry:
from .helpers.sanitize import seed_rotation_assignee
tasks_data = data.get(CONF_TASKS, {})
if tasks_data:
new_tasks = {}
for task_id, td in tasks_data.items():
new_td = dict(td)
seed_rotation_assignee(new_td)
new_tasks[task_id] = new_td
data[CONF_TASKS] = new_tasks
minor = 4
hass.config_entries.async_update_entry(entry, data=data, minor_version=minor)
_LOGGER.info("Migrated entry %s to minor_version %s", entry.entry_id, minor)
return True
@@ -221,6 +221,78 @@ _CAL_STRINGS: dict[str, dict[str, str]] = {
"reading": "读数",
"custom": "自定义",
},
"pt-br": {
"type": "Tipo",
"interval": "Intervalo",
"interval_days": "{days} dias",
"cal_last": "Última",
"cal_day": "Dia",
"last_performed": "Última realização",
"never": "Nunca",
"manually_triggered": "Tarefa de manutenção acionada manualmente",
"sensor_triggered": "Gatilho de sensor ativado para {name}",
"cleaning": "Limpeza",
"inspection": "Inspeção",
"replacement": "Substituição",
"calibration": "Calibração",
"service": "Manutenção",
"reading": "Leitura",
"custom": "Personalizado",
},
"hu": {
"type": "Típus",
"interval": "Intervallum",
"interval_days": "{days} nap",
"cal_last": "Utolsó",
"cal_day": "Nap",
"last_performed": "Utoljára elvégezve",
"never": "Soha",
"manually_triggered": "Kézzel indított karbantartási feladat",
"sensor_triggered": "Érzékelő-trigger aktiválódott: {name}",
"cleaning": "Tisztítás",
"inspection": "Ellenőrzés",
"replacement": "Csere",
"calibration": "Kalibrálás",
"service": "Szerviz",
"reading": "Leolvasás",
"custom": "Egyéni",
},
"ko": {
"type": "유형",
"interval": "주기",
"interval_days": "{days}",
"cal_last": "마지막",
"cal_day": "",
"last_performed": "마지막 수행",
"never": "없음",
"manually_triggered": "수동으로 실행된 유지보수 작업",
"sensor_triggered": "{name} 센서 트리거 활성화됨",
"cleaning": "청소",
"inspection": "점검",
"replacement": "교체",
"calibration": "보정",
"service": "정비",
"reading": "검침",
"custom": "사용자 지정",
},
"tr": {
"type": "Tür",
"interval": "Aralık",
"interval_days": "{days} gün",
"cal_last": "Son",
"cal_day": "Gün",
"last_performed": "Son yapılma",
"never": "Hiç",
"manually_triggered": "Elle tetiklenen bakım görevi",
"sensor_triggered": "{name} için sensör tetikleyicisi etkinleşti",
"cleaning": "Temizlik",
"inspection": "Kontrol",
"replacement": "Değişim",
"calibration": "Kalibrasyon",
"service": "Servis",
"reading": "Sayaç okuma",
"custom": "Özel",
},
}
@@ -84,7 +84,7 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA
"""Handle a config flow for Maintenance Supporter."""
VERSION = 1
MINOR_VERSION = 3
MINOR_VERSION = 4
def __init__(self) -> None:
"""Initialize the config flow."""
@@ -202,6 +202,34 @@ _TEST_NOTIFICATION_RESULTS: dict[str, dict[str, str]] = {
"failed": "❌ 测试通知发送失败。请验证您的服务配置。",
"push_message": "🔧 测试通知 — 您的通知设置已生效!",
},
"pt-br": {
"success": "✅ Notificação de teste enviada — seu serviço funciona. Se um dispositivo específico não receber nada, verifique esse dispositivo dentro do seu grupo de notificação (e os logs do Home Assistant).",
"no_service": "⚠️ Nenhum serviço de notificação configurado. Configure um serviço primeiro em Configurações Gerais.",
"invalid_service": "❌ O formato do serviço de notificação é inválido. Use 'notify.nome_do_servico'.",
"failed": "❌ Falha ao enviar a notificação de teste. Verifique a configuração do serviço.",
"push_message": "🔧 Notificação de teste — sua configuração de notificações está funcionando!",
},
"hu": {
"success": "✅ Tesztértesítés elküldve — a szolgáltatás működik. Ha egy adott eszközre nem érkezik semmi, ellenőrizze az eszközt az értesítési csoportban (és a Home Assistant naplóit).",
"no_service": "⚠️ Nincs értesítési szolgáltatás beállítva. Először állítson be egyet az Általános beállításokban.",
"invalid_service": "❌ Az értesítési szolgáltatás formátuma érvénytelen. Használja a 'notify.szolgaltatas_nev' formát.",
"failed": "❌ A tesztértesítés küldése nem sikerült. Ellenőrizze a szolgáltatás beállításait.",
"push_message": "🔧 Tesztértesítés — az értesítési rendszere működik!",
},
"ko": {
"success": "✅ 테스트 알림을 보냈습니다 — 서비스가 작동합니다. 특정 기기에 알림이 오지 않으면 알림 그룹 내 해당 기기와 Home Assistant 로그를 확인하세요.",
"no_service": "⚠️ 알림 서비스가 설정되지 않았습니다. 먼저 일반 설정에서 서비스를 설정하세요.",
"invalid_service": "❌ 알림 서비스 형식이 잘못되었습니다. 'notify.service_name' 형식을 사용하세요.",
"failed": "❌ 테스트 알림 전송에 실패했습니다. 서비스 설정을 확인하세요.",
"push_message": "🔧 테스트 알림 — 알림 설정이 정상 작동합니다!",
},
"tr": {
"success": "✅ Test bildirimi gönderildi — servisiniz çalışıyor. Belirli bir cihaza bildirim gelmiyorsa bildirim grubunuzdaki o cihazı (ve Home Assistant günlüklerini) kontrol edin.",
"no_service": "⚠️ Yapılandırılmış bildirim servisi yok. Önce Genel Ayarlar'da bir servis yapılandırın.",
"invalid_service": "❌ Bildirim servisi biçimi geçersiz. 'notify.servis_adi' kullanın.",
"failed": "❌ Test bildirimi gönderilemedi. Servis yapılandırmanızı kontrol edin.",
"push_message": "🔧 Test bildirimi — bildirim kurulumunuz çalışıyor!",
},
}
@@ -29,6 +29,7 @@ interface Overview {
needs_now: Record<string, number>;
needs_soon: Record<string, number>;
types: string[];
excluded?: { entity_id: string; device_name: string }[];
}
export class MaintenanceBatteryFleetSection extends LitElement {
@@ -111,6 +112,27 @@ export class MaintenanceBatteryFleetSection extends LitElement {
}
}
// Manual exclude/include (#107): a rechargeable device the heuristics
// missed (or any battery the user never wants tracked) leaves the fleet;
// the restore list below the section brings it back.
private async _setExcluded(entityId: string, excluded: boolean): Promise<void> {
if (this._marking) return;
this._marking = true;
this._error = "";
try {
await this.hass.connection.sendMessagePromise({
type: "maintenance_supporter/battery_fleet/set_excluded",
entity_id: entityId,
excluded,
});
await this._load();
} catch (e) {
this._error = describeWsError(e, this._lang);
} finally {
this._marking = false;
}
}
private _shoppingLine(needs: Record<string, number>): string {
return Object.entries(needs)
.map(([type, qty]) => `${qty}× ${type}`)
@@ -170,6 +192,14 @@ export class MaintenanceBatteryFleetSection extends LitElement {
>
<ha-icon icon="mdi:battery-sync"></ha-icon>
</button>
<button
class="bf-mark bf-exclude"
title=${t("battery_fleet_exclude", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(b.entity_id, true)}
>
<ha-icon icon="mdi:eye-off-outline"></ha-icon>
</button>
</div>
`,
)}
@@ -190,6 +220,28 @@ export class MaintenanceBatteryFleetSection extends LitElement {
</div>
`
: nothing}
${ov.excluded?.length
? html`
<div class="bf-excluded">
<span class="bf-label">${t("battery_fleet_excluded", L)}</span>
${ov.excluded.map(
(x) => html`
<span class="bf-excluded-chip">
${x.device_name}
<button
class="bf-mark"
title=${t("battery_fleet_include", L)}
.disabled=${this._marking}
@click=${() => this._setExcluded(x.entity_id, false)}
>
<ha-icon icon="mdi:eye-outline"></ha-icon>
</button>
</span>
`,
)}
</div>
`
: nothing}
<div class="bf-total">${t("battery_fleet_total", L).replace("{n}", String(ov.total))}</div>
</div>
`;
@@ -315,6 +367,27 @@ export class MaintenanceBatteryFleetSection extends LitElement {
font-size: 12px;
color: var(--secondary-text-color);
}
.bf-exclude {
color: var(--secondary-text-color);
}
.bf-excluded {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
border-top: 1px solid var(--divider-color);
padding-top: 8px;
}
.bf-excluded-chip {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
color: var(--secondary-text-color);
background: var(--secondary-background-color);
border-radius: 10px;
padding: 1px 4px 1px 10px;
}
`;
}
@@ -7,6 +7,14 @@ import { cpSync, mkdirSync, rmSync } from "fs";
// this — every frontend change left orphans behind).
rmSync("../frontend/strategy/chunks", { recursive: true, force: true });
// Stamp the integration version into every bundle (helpers/bundle-version.ts
// reads it) — the panel compares it against the backend at runtime and shows
// a "reload" banner when a stale cached bundle is running (roadmap guard 2;
// the #106 follow-up class: HA's service worker serves stale-while-revalidate,
// so "I updated and restarted" routinely still runs old frontend code).
import { readFileSync } from "node:fs";
const manifestVersion = JSON.parse(readFileSync("../manifest.json", "utf-8")).version;
const common = {
bundle: true,
format: "esm",
@@ -14,6 +22,7 @@ const common = {
minify: true,
sourcemap: false,
external: [],
define: { __MS_BUNDLE_VERSION__: JSON.stringify(manifestVersion) },
};
// Panel
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Senzorový spouštěč této úlohy byl ztracen — nespustí se ani se automaticky nedokončí.",
"battery_fleet_repair": "Opravit",
"battery_fleet_exclude": "Vyloučit z flotily",
"battery_fleet_excluded": "Vyloučené",
"battery_fleet_include": "Znovu sledovat",
"battery_fleet_total": "Sledováno baterií: {n}",
"battery_fleet_setup_button": "Flotila baterií",
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny."
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.",
"update_banner": "Na serveru je novější verze Maintenance Supporter — načtěte znovu pro aktualizaci panelu.",
"update_reload": "Načíst znovu"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Opgavens sensorudløser er gået tabt — den udløses ikke og fuldføres ikke automatisk.",
"battery_fleet_repair": "Reparér",
"battery_fleet_exclude": "Udeluk fra flåden",
"battery_fleet_excluded": "Udelukket",
"battery_fleet_include": "Følg igen",
"battery_fleet_total": "{n} batterier overvåges",
"battery_fleet_setup_button": "Batteriflåde",
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier."
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.",
"update_banner": "En nyere version af Maintenance Supporter er på serveren — genindlæs for at opdatere panelet.",
"update_reload": "Genindlæs"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Der Sensor-Trigger dieser Aufgabe ist verloren gegangen — sie wird nicht ausgelöst oder automatisch abgeschlossen.",
"battery_fleet_repair": "Reparieren",
"battery_fleet_exclude": "Aus der Flotte ausschließen",
"battery_fleet_excluded": "Ausgeschlossen",
"battery_fleet_include": "Wieder aufnehmen",
"battery_fleet_total": "{n} Batterien überwacht",
"battery_fleet_setup_button": "Batterie-Flotte",
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien."
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.",
"update_banner": "Auf dem Server läuft eine neuere Version von Maintenance Supporter — neu laden, um das Panel zu aktualisieren.",
"update_reload": "Neu laden"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "This task's sensor trigger was lost — it will not fire or auto-complete.",
"battery_fleet_repair": "Repair",
"battery_fleet_exclude": "Exclude from the fleet",
"battery_fleet_excluded": "Excluded",
"battery_fleet_include": "Track again",
"battery_fleet_total": "{n} batteries tracked",
"battery_fleet_setup_button": "Battery fleet",
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries."
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.",
"update_banner": "A newer version of Maintenance Supporter is on the server — reload to update the panel.",
"update_reload": "Reload"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "sin conexión",
"battery_fleet_trigger_lost": "El disparador de sensor de esta tarea se perdió — no se activará ni se completará automáticamente.",
"battery_fleet_repair": "Reparar",
"battery_fleet_exclude": "Excluir de la flota",
"battery_fleet_excluded": "Excluidas",
"battery_fleet_include": "Seguir de nuevo",
"battery_fleet_total": "{n} baterías monitorizadas",
"battery_fleet_setup_button": "Flota de baterías",
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas."
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.",
"update_banner": "Hay una versión más reciente de Maintenance Supporter en el servidor — recarga para actualizar el panel.",
"update_reload": "Recargar"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Tehtävän anturiliipaisin on kadonnut — se ei laukea eikä valmistu automaattisesti.",
"battery_fleet_repair": "Korjaa",
"battery_fleet_exclude": "Sulje pois kannasta",
"battery_fleet_excluded": "Pois suljetut",
"battery_fleet_include": "Seuraa uudelleen",
"battery_fleet_total": "{n} paristoa seurannassa",
"battery_fleet_setup_button": "Paristokanta",
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia."
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.",
"update_banner": "Palvelimella on uudempi Maintenance Supporter -versio — lataa uudelleen päivittääksesi paneelin.",
"update_reload": "Lataa uudelleen"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "hors ligne",
"battery_fleet_trigger_lost": "Le déclencheur de capteur de cette tâche a été perdu — elle ne se déclenchera pas et ne se terminera pas automatiquement.",
"battery_fleet_repair": "Réparer",
"battery_fleet_exclude": "Exclure du parc",
"battery_fleet_excluded": "Exclues",
"battery_fleet_include": "Suivre à nouveau",
"battery_fleet_total": "{n} piles suivies",
"battery_fleet_setup_button": "Parc de piles",
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles."
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.",
"update_banner": "Une version plus récente de Maintenance Supporter est sur le serveur — rechargez pour mettre à jour le panneau.",
"update_reload": "Recharger"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "ऑफ़लाइन",
"battery_fleet_trigger_lost": "इस कार्य का सेंसर ट्रिगर खो गया है — यह सक्रिय नहीं होगा और स्वतः पूर्ण नहीं होगा।",
"battery_fleet_repair": "मरम्मत करें",
"battery_fleet_exclude": "फ्लीट से बाहर करें",
"battery_fleet_excluded": "बाहर किए गए",
"battery_fleet_include": "फिर से ट्रैक करें",
"battery_fleet_total": "{n} बैटरियाँ ट्रैक की गईं",
"battery_fleet_setup_button": "बैटरी फ्लीट",
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।"
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
"update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।",
"update_reload": "पुनः लोड करें"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Il trigger del sensore di questa attività è andato perso — non si attiverà né si completerà automaticamente.",
"battery_fleet_repair": "Ripara",
"battery_fleet_exclude": "Escludi dal parco",
"battery_fleet_excluded": "Escluse",
"battery_fleet_include": "Traccia di nuovo",
"battery_fleet_total": "{n} batterie monitorate",
"battery_fleet_setup_button": "Parco batterie",
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte."
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.",
"update_banner": "Sul server è presente una versione più recente di Maintenance Supporter — ricarica per aggiornare il pannello.",
"update_reload": "Ricarica"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "オフライン",
"battery_fleet_trigger_lost": "このタスクのセンサートリガーが失われました — 発動も自動完了もしません。",
"battery_fleet_repair": "修復",
"battery_fleet_exclude": "フリートから除外",
"battery_fleet_excluded": "除外済み",
"battery_fleet_include": "再び追跡",
"battery_fleet_total": "{n} 個の電池を監視",
"battery_fleet_setup_button": "電池フリート",
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。"
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
"update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。",
"update_reload": "再読み込み"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "frakoblet",
"battery_fleet_trigger_lost": "Oppgavens sensorutløser er tapt — den utløses ikke og fullføres ikke automatisk.",
"battery_fleet_repair": "Reparer",
"battery_fleet_exclude": "Utelukk fra flåten",
"battery_fleet_excluded": "Utelukket",
"battery_fleet_include": "Følg igjen",
"battery_fleet_total": "{n} batterier spores",
"battery_fleet_setup_button": "Batteriflåte",
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene."
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.",
"update_banner": "En nyere versjon av Maintenance Supporter er på serveren — last inn på nytt for å oppdatere panelet.",
"update_reload": "Last inn på nytt"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "De sensortrigger van deze taak is verloren gegaan — deze wordt niet geactiveerd of automatisch voltooid.",
"battery_fleet_repair": "Repareren",
"battery_fleet_exclude": "Uitsluiten van de vloot",
"battery_fleet_excluded": "Uitgesloten",
"battery_fleet_include": "Opnieuw volgen",
"battery_fleet_total": "{n} batterijen gevolgd",
"battery_fleet_setup_button": "Batterijvloot",
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen."
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.",
"update_banner": "Er staat een nieuwere versie van Maintenance Supporter op de server — herlaad om het paneel bij te werken.",
"update_reload": "Herladen"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Wyzwalacz czujnika tego zadania został utracony — nie uruchomi się ani nie zakończy automatycznie.",
"battery_fleet_repair": "Napraw",
"battery_fleet_exclude": "Wyklucz z floty",
"battery_fleet_excluded": "Wykluczone",
"battery_fleet_include": "Śledź ponownie",
"battery_fleet_total": "Śledzone baterie: {n}",
"battery_fleet_setup_button": "Flota baterii",
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie."
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.",
"update_banner": "Na serwerze jest nowsza wersja Maintenance Supporter — załaduj ponownie, aby zaktualizować panel.",
"update_reload": "Załaduj ponownie"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "O gatilho de sensor desta tarefa foi perdido — ela não será acionada nem concluída automaticamente.",
"battery_fleet_repair": "Reparar",
"battery_fleet_exclude": "Excluir da frota",
"battery_fleet_excluded": "Excluídas",
"battery_fleet_include": "Acompanhar novamente",
"battery_fleet_total": "{n} baterias monitorizadas",
"battery_fleet_setup_button": "Frota de baterias",
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas."
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.",
"update_banner": "Há uma versão mais recente do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
"update_reload": "Recarregar"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "не в сети",
"battery_fleet_trigger_lost": "Сенсорный триггер этой задачи утерян — она не сработает и не завершится автоматически.",
"battery_fleet_repair": "Восстановить",
"battery_fleet_exclude": "Исключить из парка",
"battery_fleet_excluded": "Исключены",
"battery_fleet_include": "Снова отслеживать",
"battery_fleet_total": "Отслеживается батарей: {n}",
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми."
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
"update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.",
"update_reload": "Перезагрузить"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Uppgiftens sensorutlösare har gått förlorad — den utlöses inte och slutförs inte automatiskt.",
"battery_fleet_repair": "Reparera",
"battery_fleet_exclude": "Uteslut från flottan",
"battery_fleet_excluded": "Uteslutna",
"battery_fleet_include": "Spåra igen",
"battery_fleet_total": "{n} batterier spåras",
"battery_fleet_setup_button": "Batteriflotta",
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier."
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.",
"update_banner": "En nyare version av Maintenance Supporter finns på servern — ladda om för att uppdatera panelen.",
"update_reload": "Ladda om"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "не в мережі",
"battery_fleet_trigger_lost": "Сенсорний тригер цього завдання втрачено — воно не спрацює і не завершиться автоматично.",
"battery_fleet_repair": "Відновити",
"battery_fleet_exclude": "Виключити з парку",
"battery_fleet_excluded": "Виключені",
"battery_fleet_include": "Знову відстежувати",
"battery_fleet_total": "Відстежується батарей: {n}",
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма."
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
"update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.",
"update_reload": "Перезавантажити"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "离线",
"battery_fleet_trigger_lost": "此任务的传感器触发器已丢失——它不会触发,也不会自动完成。",
"battery_fleet_repair": "修复",
"battery_fleet_exclude": "从电池群中排除",
"battery_fleet_excluded": "已排除",
"battery_fleet_include": "重新跟踪",
"battery_fleet_total": "已跟踪 {n} 个电池",
"battery_fleet_setup_button": "电池群",
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。"
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
"update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。",
"update_reload": "重新加载"
}
@@ -19,6 +19,8 @@
* show_window_chips: true # default true; hide for embedded use
* show_user_filter: true # default true
* user_filter: "" # "" | "current_user" | "<uuid>"
* show_object_filter: true # default true; dropdown appears with 2+ objects
* object_filter: "" # "" | "<entry_id>" | "<object name>" (Discussion #83)
*/
import { LitElement, html, css, nothing } from "lit";
@@ -47,6 +49,9 @@ interface CalendarCardConfig {
show_window_chips?: boolean;
show_user_filter?: boolean;
user_filter?: string;
/** Discussion #83 — in-card object dropdown + optional preselect. */
show_object_filter?: boolean;
object_filter?: string;
/** v2.2.0 when set, the card renders past N days from history instead
* of forward N days from next_due. Mutually exclusive with window_days. */
past_days?: PastDays;
@@ -62,6 +67,7 @@ export class MaintenanceCalendarCard extends LitElement {
@state() private _windowDays: WindowDays = 30;
@state() private _pastDays: PastDays | 0 = 0; // 0 = forward mode
@state() private _userFilter = "";
@state() private _objectFilter = "";
@state() private _unsub: (() => void) | null = null;
private _dataLoaded = false;
@@ -92,6 +98,9 @@ export class MaintenanceCalendarCard extends LitElement {
if (typeof config.user_filter === "string") {
this._userFilter = config.user_filter;
}
if (typeof config.object_filter === "string") {
this._objectFilter = config.object_filter;
}
}
getCardSize(): number {
@@ -224,12 +233,26 @@ export class MaintenanceCalendarCard extends LitElement {
: this._userFilter;
}
// Object filter (#83): the config value may be an entry_id or an object
// NAME (friendlier in hand-written YAML) — resolve against the loaded
// objects; an unknown value falls back to "all" instead of a blank card.
const showObjectFilter = this._config.show_object_filter !== false && this._objects.length > 1;
let filterEntryId: string | null = null;
if (this._objectFilter) {
const needle = this._objectFilter.toLowerCase();
const match = this._objects.find(
(o) => o.entry_id === this._objectFilter || o.object.name.toLowerCase() === needle,
);
filterEntryId = match?.entry_id ?? null;
}
const objects = filterEntryId ? this._objects.filter((o) => o.entry_id === filterEntryId) : this._objects;
const today = new Date();
today.setHours(0, 0, 0, 0);
const isPast = this._pastDays > 0;
const buckets = isPast
? buildPastBuckets(this._objects, today, this._pastDays, userFilter)
: buildCalendarBuckets(this._objects, today, this._windowDays, userFilter);
? buildPastBuckets(objects, today, this._pastDays, userFilter)
: buildCalendarBuckets(objects, today, this._windowDays, userFilter);
const todayIso = isoDateLocal(today);
// Year view AND past views collapse empty days — past windows can be
@@ -363,6 +386,22 @@ export class MaintenanceCalendarCard extends LitElement {
</select>
`
: nothing}
${showObjectFilter
? html`
<select class="cal-user-filter"
.value=${filterEntryId ?? ""}
@change=${(e: Event) => {
this._objectFilter = (e.target as HTMLSelectElement).value;
}}>
<option value="">${t("all_objects", L)}</option>
${[...this._objects]
.sort((a, b) => a.object.name.localeCompare(b.object.name))
.map(
(o) => html`<option value=${o.entry_id} ?selected=${o.entry_id === filterEntryId}>${o.object.name}</option>`,
)}
</select>
`
: nothing}
</div>
`
: nothing}
@@ -417,6 +456,9 @@ class MaintenanceCalendarCardEditor extends LitElement {
if (key === "show_user_filter" && value === true) {
delete (newConfig as unknown as Record<string, unknown>).show_user_filter;
}
if (key === "show_object_filter" && value === true) {
delete (newConfig as unknown as Record<string, unknown>).show_object_filter;
}
if (key === "title" && (!value || (typeof value === "string" && value.trim() === ""))) {
delete (newConfig as unknown as Record<string, unknown>).title;
}
@@ -513,6 +555,22 @@ class MaintenanceCalendarCardEditor extends LitElement {
</option>
</select>
</div>
<div class="row toggle">
<label for="objf">Show object filter dropdown</label>
<input
id="objf"
type="checkbox"
.checked=${this._config.show_object_filter !== false}
@change=${(e: Event) =>
this._valueChanged(
"show_object_filter",
(e.target as HTMLInputElement).checked,
)}
/>
</div>
<div class="hint">
Pre-select one object via YAML: object_filter: "&lt;object name&gt;".
</div>
</div>
`;
}
@@ -2,6 +2,7 @@
import { LitElement, html, nothing } from "lit";
import { isSafeHttpUrl } from "./helpers/url";
import { isStaleBundle } from "./helpers/bundle-version";
import { customElement, property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
import { LS_KEYS } from "./helpers/storage-keys";
@@ -142,6 +143,8 @@ export class MaintenanceSupporterPanel extends LitElement {
// Battery Fleet: offer one-click setup only when Battery Notes is present
// and the fleet isn't set up yet.
@state() private _batteryFleetSetupAvailable = false;
@state() private _staleBundle = false;
private _staleChecked = false;
private _toastTimer: ReturnType<typeof setTimeout> | null = null;
private _dismissedSuggestions = new Set<string>();
@@ -392,6 +395,21 @@ export class MaintenanceSupporterPanel extends LitElement {
.catch(() => {
this._batteryFleetSetupAvailable = false;
});
// Stale-bundle handshake (roadmap guard 2): compare the version esbuild
// stamped into this bundle with the backend's — a mismatch means the
// browser/service worker is still serving an OLD cached frontend, and no
// amount of HA restarts fixes that. Checked once per panel lifetime.
if (!this._staleChecked) {
this._staleChecked = true;
this.hass.connection
.sendMessagePromise<{ version: string }>({ type: "maintenance_supporter/version" })
.then((v) => {
this._staleBundle = isStaleBundle(v?.version);
})
.catch(() => {
/* backend too old to answer — no banner */
});
}
if (statsResult) this._stats = statsResult as StatisticsResponse;
if (budgetResult) this._budget = budgetResult as BudgetStatus;
if (groupsResult) this._groups = (groupsResult as { groups: Record<string, MaintenanceGroup> }).groups || {};
@@ -1827,6 +1845,17 @@ export class MaintenanceSupporterPanel extends LitElement {
render() {
return html`
<div class="panel">
${this._staleBundle
? html`
<div class="update-banner" role="status">
<ha-icon icon="mdi:update"></ha-icon>
<span>${t("update_banner", this._lang)}</span>
<ha-button appearance="plain" @click=${() => location.reload()}>
${t("update_reload", this._lang)}
</ha-button>
</div>
`
: nothing}
${this.narrow || this._view !== "overview" ? this._renderHeader() : nothing}
<div class="content">
${this._view === "overview"
@@ -735,6 +735,20 @@ export const panelStyles = css`
position: relative;
}
/* Stale-bundle handshake banner (roadmap guard 2) */
.update-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
background: color-mix(in srgb, var(--primary-color) 14%, var(--card-background-color, #fff));
border-bottom: 1px solid var(--divider-color);
font-size: 14px;
}
.update-banner span {
flex: 1;
}
.popup-menu {
position: absolute;
top: 100%;
@@ -1223,8 +1237,10 @@ export const panelStyles = css`
}
:host([narrow]) .tab {
padding: 12px 16px;
font-size: 14px;
/* Tight enough that the four tabs fit 412px in the longest languages
Ukrainian ("Налаштування") overflowed at 12px 16px (overflow sweep). */
padding: 12px 8px;
font-size: 13px;
}
:host([narrow]) .task-header {
@@ -1435,7 +1451,7 @@ export const panelStyles = css`
.kpi-value { font-size: 14px; }
.kpi-value-large { font-size: 18px; }
.two-column-layout { grid-template-columns: 1fr; }
.tab { padding: 12px 16px; font-size: 14px; }
.tab { padding: 12px 8px; font-size: 13px; }
.task-header { flex-direction: column; align-items: flex-start; }
.task-header-actions { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
.filter-bar { flex-wrap: wrap; }
@@ -52,8 +52,8 @@ if (!STORE.en) STORE.en = EN as Translations;
/** Languages available as runtime-loaded JSON. Keep in sync with locales/. */
const SUPPORTED_LANGS = new Set<string>([
"de", "nl", "fr", "it", "es", "pt", "ru", "uk", "pl", "cs", "sv", "zh",
"da", "fi", "nb", "ja", "hi",
"de", "nl", "fr", "it", "es", "pt", "pt-br", "ru", "uk", "pl", "cs", "sv", "zh",
"da", "fi", "nb", "ja", "hi", "hu", "ko", "tr",
]);
/** Served base for the runtime locale files (mirrors LOCALES_URL in const.py). */
@@ -61,9 +61,16 @@ const LOCALES_BASE = "/maintenance_supporter_locales";
const _localeInflight = _localeGlobals.inflight;
/** Normalize an HA language code to our 2-letter table key. */
/** Normalize an HA language code to our table key.
*
* Brazilian Portuguese is the one regional variant with its OWN table
* ("pt-br") it must not collapse into European "pt". Mirrors
* normalize_language_code in helpers/i18n.py.
*/
function normLang(lang?: string): string {
return (lang || DEFAULT_LANG).substring(0, 2).toLowerCase();
const l = (lang || DEFAULT_LANG).toLowerCase();
if (l.startsWith("pt") && l.endsWith("br")) return "pt-br";
return l.substring(0, 2);
}
/** Get a localized string. Falls back to English, then to the key itself. */
@@ -117,15 +124,16 @@ export function setLocale(lang: string, table: Translations): void {
STORE[normLang(lang)] = table;
}
/** Map language prefix to BCP-47 locale for date formatting. */
/** Map language table key to BCP-47 locale for date formatting. */
function langToLocale(lang?: string): string {
const l = (lang || "en").substring(0, 2).toLowerCase();
const l = normLang(lang);
const map: Record<string, string> = {
de: "de-DE", en: "en-US", nl: "nl-NL", fr: "fr-FR", it: "it-IT", es: "es-ES", pt: "pt-PT", ru: "ru-RU", uk: "uk-UA", zh: "zh-CN",
da: "da-DK", fi: "fi-FI", nb: "nb-NO", ja: "ja-JP", hi: "hi-IN",
// pl/cs/sv were missing and silently fell back to en-US — Polish users
// saw MM/DD dates (caught by the live multi-language check, 2026-07-19).
pl: "pl-PL", cs: "cs-CZ", sv: "sv-SE",
"pt-br": "pt-BR", hu: "hu-HU", ko: "ko-KR", tr: "tr-TR",
};
return map[l] ?? "en-US";
}
@@ -224,8 +232,7 @@ export function formatInterval(
/** Localized weekday name (0=Mon 6=Sun) via Intl 2024-01-01 is a Monday.
* Single source for the dialog selectors AND formatRecurrence (DRY). */
export function weekdayName(i: number, lang?: string, style: "long" | "short" = "long"): string {
const locale = (lang || "en").substring(0, 2);
return new Date(Date.UTC(2024, 0, 1 + i)).toLocaleDateString(locale, { weekday: style, timeZone: "UTC" });
return new Date(Date.UTC(2024, 0, 1 + i)).toLocaleDateString(langToLocale(lang), { weekday: style, timeZone: "UTC" });
}
/** Recurrence shape carried on the WS payload (see types.TaskSchedule). */
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Senzorový spouštěč této úlohy byl ztracen — nespustí se ani se automaticky nedokončí.",
"battery_fleet_repair": "Opravit",
"battery_fleet_exclude": "Vyloučit z flotily",
"battery_fleet_excluded": "Vyloučené",
"battery_fleet_include": "Znovu sledovat",
"battery_fleet_total": "Sledováno baterií: {n}",
"battery_fleet_setup_button": "Flotila baterií",
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny."
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.",
"update_banner": "Na serveru je novější verze Maintenance Supporter — načtěte znovu pro aktualizaci panelu.",
"update_reload": "Načíst znovu"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Opgavens sensorudløser er gået tabt — den udløses ikke og fuldføres ikke automatisk.",
"battery_fleet_repair": "Reparér",
"battery_fleet_exclude": "Udeluk fra flåden",
"battery_fleet_excluded": "Udelukket",
"battery_fleet_include": "Følg igen",
"battery_fleet_total": "{n} batterier overvåges",
"battery_fleet_setup_button": "Batteriflåde",
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier."
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.",
"update_banner": "En nyere version af Maintenance Supporter er på serveren — genindlæs for at opdatere panelet.",
"update_reload": "Genindlæs"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Der Sensor-Trigger dieser Aufgabe ist verloren gegangen — sie wird nicht ausgelöst oder automatisch abgeschlossen.",
"battery_fleet_repair": "Reparieren",
"battery_fleet_exclude": "Aus der Flotte ausschließen",
"battery_fleet_excluded": "Ausgeschlossen",
"battery_fleet_include": "Wieder aufnehmen",
"battery_fleet_total": "{n} Batterien überwacht",
"battery_fleet_setup_button": "Batterie-Flotte",
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien."
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.",
"update_banner": "Auf dem Server läuft eine neuere Version von Maintenance Supporter — neu laden, um das Panel zu aktualisieren.",
"update_reload": "Neu laden"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "This task's sensor trigger was lost — it will not fire or auto-complete.",
"battery_fleet_repair": "Repair",
"battery_fleet_exclude": "Exclude from the fleet",
"battery_fleet_excluded": "Excluded",
"battery_fleet_include": "Track again",
"battery_fleet_total": "{n} batteries tracked",
"battery_fleet_setup_button": "Battery fleet",
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries."
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.",
"update_banner": "A newer version of Maintenance Supporter is on the server — reload to update the panel.",
"update_reload": "Reload"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "sin conexión",
"battery_fleet_trigger_lost": "El disparador de sensor de esta tarea se perdió — no se activará ni se completará automáticamente.",
"battery_fleet_repair": "Reparar",
"battery_fleet_exclude": "Excluir de la flota",
"battery_fleet_excluded": "Excluidas",
"battery_fleet_include": "Seguir de nuevo",
"battery_fleet_total": "{n} baterías monitorizadas",
"battery_fleet_setup_button": "Flota de baterías",
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas."
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.",
"update_banner": "Hay una versión más reciente de Maintenance Supporter en el servidor — recarga para actualizar el panel.",
"update_reload": "Recargar"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Tehtävän anturiliipaisin on kadonnut — se ei laukea eikä valmistu automaattisesti.",
"battery_fleet_repair": "Korjaa",
"battery_fleet_exclude": "Sulje pois kannasta",
"battery_fleet_excluded": "Pois suljetut",
"battery_fleet_include": "Seuraa uudelleen",
"battery_fleet_total": "{n} paristoa seurannassa",
"battery_fleet_setup_button": "Paristokanta",
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia."
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.",
"update_banner": "Palvelimella on uudempi Maintenance Supporter -versio — lataa uudelleen päivittääksesi paneelin.",
"update_reload": "Lataa uudelleen"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "hors ligne",
"battery_fleet_trigger_lost": "Le déclencheur de capteur de cette tâche a été perdu — elle ne se déclenchera pas et ne se terminera pas automatiquement.",
"battery_fleet_repair": "Réparer",
"battery_fleet_exclude": "Exclure du parc",
"battery_fleet_excluded": "Exclues",
"battery_fleet_include": "Suivre à nouveau",
"battery_fleet_total": "{n} piles suivies",
"battery_fleet_setup_button": "Parc de piles",
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles."
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.",
"update_banner": "Une version plus récente de Maintenance Supporter est sur le serveur — rechargez pour mettre à jour le panneau.",
"update_reload": "Recharger"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "ऑफ़लाइन",
"battery_fleet_trigger_lost": "इस कार्य का सेंसर ट्रिगर खो गया है — यह सक्रिय नहीं होगा और स्वतः पूर्ण नहीं होगा।",
"battery_fleet_repair": "मरम्मत करें",
"battery_fleet_exclude": "फ्लीट से बाहर करें",
"battery_fleet_excluded": "बाहर किए गए",
"battery_fleet_include": "फिर से ट्रैक करें",
"battery_fleet_total": "{n} बैटरियाँ ट्रैक की गईं",
"battery_fleet_setup_button": "बैटरी फ्लीट",
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।"
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
"update_banner": "सर्वर पर Maintenance Supporter का नया संस्करण है — पैनल अपडेट करने के लिए पुनः लोड करें।",
"update_reload": "पुनः लोड करें"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Il trigger del sensore di questa attività è andato perso — non si attiverà né si completerà automaticamente.",
"battery_fleet_repair": "Ripara",
"battery_fleet_exclude": "Escludi dal parco",
"battery_fleet_excluded": "Escluse",
"battery_fleet_include": "Traccia di nuovo",
"battery_fleet_total": "{n} batterie monitorate",
"battery_fleet_setup_button": "Parco batterie",
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte."
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.",
"update_banner": "Sul server è presente una versione più recente di Maintenance Supporter — ricarica per aggiornare il pannello.",
"update_reload": "Ricarica"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "オフライン",
"battery_fleet_trigger_lost": "このタスクのセンサートリガーが失われました — 発動も自動完了もしません。",
"battery_fleet_repair": "修復",
"battery_fleet_exclude": "フリートから除外",
"battery_fleet_excluded": "除外済み",
"battery_fleet_include": "再び追跡",
"battery_fleet_total": "{n} 個の電池を監視",
"battery_fleet_setup_button": "電池フリート",
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。"
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
"update_banner": "サーバーに新しいバージョンの Maintenance Supporter があります — 再読み込みしてパネルを更新してください。",
"update_reload": "再読み込み"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "frakoblet",
"battery_fleet_trigger_lost": "Oppgavens sensorutløser er tapt — den utløses ikke og fullføres ikke automatisk.",
"battery_fleet_repair": "Reparer",
"battery_fleet_exclude": "Utelukk fra flåten",
"battery_fleet_excluded": "Utelukket",
"battery_fleet_include": "Følg igjen",
"battery_fleet_total": "{n} batterier spores",
"battery_fleet_setup_button": "Batteriflåte",
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene."
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.",
"update_banner": "En nyere versjon av Maintenance Supporter er på serveren — last inn på nytt for å oppdatere panelet.",
"update_reload": "Last inn på nytt"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "De sensortrigger van deze taak is verloren gegaan — deze wordt niet geactiveerd of automatisch voltooid.",
"battery_fleet_repair": "Repareren",
"battery_fleet_exclude": "Uitsluiten van de vloot",
"battery_fleet_excluded": "Uitgesloten",
"battery_fleet_include": "Opnieuw volgen",
"battery_fleet_total": "{n} batterijen gevolgd",
"battery_fleet_setup_button": "Batterijvloot",
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen."
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.",
"update_banner": "Er staat een nieuwere versie van Maintenance Supporter op de server — herlaad om het paneel bij te werken.",
"update_reload": "Herladen"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Wyzwalacz czujnika tego zadania został utracony — nie uruchomi się ani nie zakończy automatycznie.",
"battery_fleet_repair": "Napraw",
"battery_fleet_exclude": "Wyklucz z floty",
"battery_fleet_excluded": "Wykluczone",
"battery_fleet_include": "Śledź ponownie",
"battery_fleet_total": "Śledzone baterie: {n}",
"battery_fleet_setup_button": "Flota baterii",
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie."
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.",
"update_banner": "Na serwerze jest nowsza wersja Maintenance Supporter — załaduj ponownie, aby zaktualizować panel.",
"update_reload": "Załaduj ponownie"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "O gatilho de sensor desta tarefa foi perdido — ela não será acionada nem concluída automaticamente.",
"battery_fleet_repair": "Reparar",
"battery_fleet_exclude": "Excluir da frota",
"battery_fleet_excluded": "Excluídas",
"battery_fleet_include": "Acompanhar novamente",
"battery_fleet_total": "{n} baterias monitorizadas",
"battery_fleet_setup_button": "Frota de baterias",
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas."
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.",
"update_banner": "Há uma versão mais recente do Maintenance Supporter no servidor — recarregue para atualizar o painel.",
"update_reload": "Recarregar"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "не в сети",
"battery_fleet_trigger_lost": "Сенсорный триггер этой задачи утерян — она не сработает и не завершится автоматически.",
"battery_fleet_repair": "Восстановить",
"battery_fleet_exclude": "Исключить из парка",
"battery_fleet_excluded": "Исключены",
"battery_fleet_include": "Снова отслеживать",
"battery_fleet_total": "Отслеживается батарей: {n}",
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми."
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
"update_banner": "На сервере более новая версия Maintenance Supporter — перезагрузите, чтобы обновить панель.",
"update_reload": "Перезагрузить"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "offline",
"battery_fleet_trigger_lost": "Uppgiftens sensorutlösare har gått förlorad — den utlöses inte och slutförs inte automatiskt.",
"battery_fleet_repair": "Reparera",
"battery_fleet_exclude": "Uteslut från flottan",
"battery_fleet_excluded": "Uteslutna",
"battery_fleet_include": "Spåra igen",
"battery_fleet_total": "{n} batterier spåras",
"battery_fleet_setup_button": "Batteriflotta",
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier."
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.",
"update_banner": "En nyare version av Maintenance Supporter finns på servern — ladda om för att uppdatera panelen.",
"update_reload": "Ladda om"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "не в мережі",
"battery_fleet_trigger_lost": "Сенсорний тригер цього завдання втрачено — воно не спрацює і не завершиться автоматично.",
"battery_fleet_repair": "Відновити",
"battery_fleet_exclude": "Виключити з парку",
"battery_fleet_excluded": "Виключені",
"battery_fleet_include": "Знову відстежувати",
"battery_fleet_total": "Відстежується батарей: {n}",
"battery_fleet_setup_button": "Парк батарей",
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма."
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
"update_banner": "На сервері новіша версія Maintenance Supporter — перезавантажте, щоб оновити панель.",
"update_reload": "Перезавантажити"
}
@@ -800,7 +800,12 @@
"battery_fleet_offline": "离线",
"battery_fleet_trigger_lost": "此任务的传感器触发器已丢失——它不会触发,也不会自动完成。",
"battery_fleet_repair": "修复",
"battery_fleet_exclude": "从电池群中排除",
"battery_fleet_excluded": "已排除",
"battery_fleet_include": "重新跟踪",
"battery_fleet_total": "已跟踪 {n} 个电池",
"battery_fleet_setup_button": "电池群",
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。"
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
"update_banner": "服务器上有更新版本的 Maintenance Supporter——请重新加载以更新面板。",
"update_reload": "重新加载"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@ from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass, field
from datetime import date
from datetime import date, timedelta
from typing import Any
from homeassistant.core import HomeAssistant
@@ -61,6 +61,41 @@ NATIVE_LOW_PERCENT = 20
# offline — that's exactly the one you must not hide).
_NO_READING = {"unavailable", "unknown", "none", ""}
# How long a NATIVE battery that was last seen LOW stays in the fleet after
# its entity goes unavailable. Battery Notes covers this case via its retained
# ``battery_low`` attribute; native entities have no equivalent, so without a
# snapshot the battery would vanish at the exact moment it died and took its
# device offline. Bounded so a permanently removed device eventually drops.
_NATIVE_RETENTION = timedelta(hours=48)
# Heuristic (sensors WITHOUT device_class): a %-sensor whose object_id talks
# about a battery — some Zigbee2MQTT/ESPHome devices ship battery levels
# without the device class. Deliberately strict: the exclusion words keep out
# charging electronics and home-storage state-of-charge sensors (a Powerwall
# is not a battery you replace).
_HEURISTIC_EXCLUDE = ("charging", "current", "power", "voltage", "energy", "load", "soc", "state_of_charge", "storage", "temp")
def _is_native_battery_sensor(state: Any) -> bool:
"""Whether a sensor state looks like a replaceable-battery level."""
attrs = state.attributes
if attrs.get("device_class") == "battery":
return True
if attrs.get("unit_of_measurement") != "%":
return False
object_id = state.entity_id.split(".", 1)[1]
if "battery" not in object_id:
return False
return not any(word in object_id for word in _HEURISTIC_EXCLUDE)
def _native_snapshot_cache(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
"""Runtime cache of last-known native battery readings (per entity_id)."""
from ..const import DOMAIN
cache: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {}).setdefault("battery_fleet_native_cache", {})
return cache
def _norm_type(raw: Any) -> str:
"""Canonicalize a battery-type label for grouping (upper, trimmed)."""
@@ -188,6 +223,46 @@ def _level_of(state_val: str) -> float | None:
return None
def fleet_excluded_entities(hass: HomeAssistant) -> set[str]:
"""Manually excluded battery entity_ids, stored on the fleet object entry.
Inlined lookup (not via battery_fleet_setup.find_fleet_entry) to keep this
module import-cycle-free setup imports the aggregation, not vice versa.
"""
from ..const import CONF_OBJECT, DOMAIN
for entry in hass.config_entries.async_entries(DOMAIN):
obj = entry.data.get(CONF_OBJECT, {})
if obj.get("battery_fleet"):
return set(obj.get("battery_fleet_excluded") or [])
return set()
def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
"""Whether a device recharges itself — its battery is never REPLACED.
Issue #107: a Roborock's native battery sensor reads "low" mid-clean, but
nobody swaps its cells. Heuristics (native pickup only an explicit
Battery Notes note always wins): the device also has a vacuum/lawn_mower
entity, exposes a ``battery_charging`` binary, or is a Companion-app
phone/tablet (``mobile_app`` identifiers).
"""
if not device_id:
return False
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
device = dr.async_get(hass).async_get(device_id)
if device and any(domain == "mobile_app" for domain, _ in device.identifiers):
return True
for reg_entry in er.async_entries_for_device(er.async_get(hass), device_id, include_disabled_entities=True):
if reg_entry.domain in ("vacuum", "lawn_mower"):
return True
if reg_entry.domain == "binary_sensor" and (reg_entry.device_class or reg_entry.original_device_class) == "battery_charging":
return True
return False
def read_batteries(hass: HomeAssistant) -> list[Battery]:
"""Read the battery fleet from HA state — Battery Notes AND native.
@@ -197,16 +272,25 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
unavailable/unknown but RETAINS its last-known ``battery_low`` so a
dead battery that took its device offline stays visible.
* **Native** ``device_class: battery`` entities (a %-sensor and/or a
battery-low binary), grouped per device, give a degraded view (type
"Unknown", quantity 1, no forecast). A device already covered by a
Battery Notes note is skipped (dedup by the note's source entity + its
device) so it isn't counted twice.
battery-low binary) plus %-sensors matching the strict battery-name
heuristic for devices that ship no device class grouped per device,
give a degraded view (type "Unknown", quantity 1, no forecast). A
device already covered by a Battery Notes note is skipped (dedup by
the note's source entity + its device) so it isn't counted twice;
self-charging devices (vacuums, mowers, phones see
:func:`_is_self_charging`) are skipped entirely. A native battery
last seen LOW that goes unavailable is retained from a runtime
snapshot for ``_NATIVE_RETENTION`` (the Battery Notes path gets this
for free via its retained ``battery_low`` attribute).
* Manually excluded entity_ids (fleet detail exclude) are dropped from
BOTH passes.
"""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
excluded = fleet_excluded_entities(hass)
out: list[Battery] = []
covered_sources: set[str] = set()
@@ -217,17 +301,36 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
attrs = state.attributes
if attrs.get("device_class") != "battery" or "battery_type" not in attrs:
continue
level = _level_of(state.state)
available = state.state not in _NO_READING and level is not None
# B2 (roadmap 2026-07-22 audit): ONE low floor across both passes.
# Battery Notes' own threshold (default 10 %) still counts via its
# battery_low flag, but the fleet-wide NATIVE_LOW_PERCENT floor is
# OR-ed in — a CR2032 at 11.5 % was "healthy" here while the same
# level counted low in the native pass. A HIGHER Battery Notes
# threshold (e.g. 30 %) still wins through battery_low.
low = bool(attrs.get("battery_low")) or (level is not None and level <= NATIVE_LOW_PERCENT)
last_replaced = _parse_last_replaced(attrs.get("battery_last_replaced"))
# B1 (roadmap 2026-07-22 audit): a forecast-only note — no level
# sensor, so the state reads unknown forever — must SURVIVE when it
# carries a replacement date: that date is all `_predicted_date`
# needs, and dropping these hid 11 overdue batteries in a live fleet.
# Offline AND not low AND no date = pure connectivity noise → drop.
if not available and not low and last_replaced is None:
continue
# B3: only a KEPT note covers its source/device — a dropped dead note
# must not suppress the native fallback for its own device (a device
# with a dead note and a working level sensor was invisible in BOTH
# passes).
src = attrs.get("source_entity_id")
if src:
covered_sources.add(src)
reg = ent_reg.async_get(state.entity_id)
if reg and reg.device_id:
covered_devices.add(reg.device_id)
level = _level_of(state.state)
available = state.state not in _NO_READING and level is not None
low = bool(attrs.get("battery_low"))
# Offline AND not last-known-low = pure connectivity noise → drop it.
if not available and not low:
# An EXCLUDED note still covers (above): exclusion hides the battery —
# it must not resurrect as a degraded native "Unknown" row.
if state.entity_id in excluded:
continue
out.append(
Battery(
@@ -237,7 +340,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
quantity=int(attrs.get("battery_quantity") or 1),
low=low,
level=level,
last_replaced=_parse_last_replaced(attrs.get("battery_last_replaced")),
last_replaced=last_replaced,
available=available,
source="battery_notes",
)
@@ -248,17 +351,25 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
native: dict[str, dict[str, Any]] = {}
for domain in ("sensor", "binary_sensor"):
for state in hass.states.async_all(domain):
if state.attributes.get("device_class") != "battery":
# Sensors: device_class battery OR the strict name/% heuristic
# (Zigbee2MQTT/ESPHome levels without a device class). Binaries:
# device_class only — name-guessing booleans is too risky.
if domain == "sensor":
if not _is_native_battery_sensor(state):
continue
elif state.attributes.get("device_class") != "battery":
continue
eid = state.entity_id
if "battery_type" in state.attributes: # Battery Notes battery_plus — handled above
continue
if eid in covered_sources:
if eid in covered_sources or eid in excluded:
continue
reg = ent_reg.async_get(eid)
dev_id = reg.device_id if reg else None
if dev_id and dev_id in covered_devices:
continue
if _is_self_charging(hass, dev_id): # #107: vacuums/mowers/phones
continue
key = dev_id or eid
rec = native.setdefault(
key,
@@ -273,6 +384,8 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
if rec["name"] is None and friendly:
rec["name"] = friendly
snapshot_cache = _native_snapshot_cache(hass)
now = dt_util.utcnow()
for rec in native.values():
level = _level_of(rec["level_state"]) if rec["level_state"] is not None else None
low_state = rec["low_state"]
@@ -283,6 +396,19 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
low = low_available and str(low_state).lower() in ("on", "true", "1")
else:
low = level is not None and level <= NATIVE_LOW_PERCENT
if available:
# Remember the last real reading — the retention path below needs
# it once the entity goes unavailable.
snapshot_cache[rec["eid"]] = {"low": low, "level": level, "ts": now}
elif not low:
# Native dead-battery retention: an entity that was LOW and then
# went unavailable (the battery died and took the device offline)
# stays visible for _NATIVE_RETENTION instead of vanishing at the
# exact moment it needs replacing.
snap = snapshot_cache.get(rec["eid"])
if snap and snap.get("low") and now - snap["ts"] <= _NATIVE_RETENTION:
low = True
level = snap.get("level")
if not available and not low:
continue
name = rec["name"]
@@ -315,11 +441,9 @@ def has_battery_notes(hass: HomeAssistant) -> bool:
def has_batteries(hass: HomeAssistant) -> bool:
"""Whether ANY battery is trackable — Battery Notes OR native. Gates setup."""
for domain in ("sensor", "binary_sensor"):
for state in hass.states.async_all(domain):
if state.attributes.get("device_class") == "battery":
return True
return False
if any(_is_native_battery_sensor(s) for s in hass.states.async_all("sensor")):
return True
return any(s.attributes.get("device_class") == "battery" for s in hass.states.async_all("binary_sensor"))
def compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON_DAYS) -> BatteryOverview:
@@ -346,6 +470,7 @@ __all__ = [
"build_overview",
"compute_overview",
"discover_battery_types",
"fleet_excluded_entities",
"has_batteries",
"has_battery_notes",
"lifetime_months",
@@ -49,10 +49,10 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
"""
from ..websocket.objects import async_create_object
from ..websocket.tasks_persist import async_persist_task
from .i18n import normalize_language
from .i18n import normalize_language, normalize_language_code
from .parts import normalize_part
lang = (language or normalize_language(hass))[:2].lower()
lang = normalize_language_code(language) if language else normalize_language(hass)
types = discover_battery_types(hass) # {TYPE: total_qty}
existing = find_fleet_entry(hass)
@@ -220,6 +220,30 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
return {"marked": len(targets), "pressed": pressed, "consumed": consumed}
def set_battery_excluded(hass: HomeAssistant, entity_id: str, excluded: bool) -> bool:
"""Persist a manual exclude/include of one battery on the fleet object.
Issue #107: some tracked batteries should never appear (a rechargeable
device the heuristics missed, a neighbour's sensor, …). Stored as
``battery_fleet_excluded`` on the fleet object dict. Returns False when
no fleet exists yet.
"""
entry = find_fleet_entry(hass)
if entry is None:
return False
new_data = dict(entry.data)
obj = dict(new_data.get(CONF_OBJECT, {}))
current = set(obj.get("battery_fleet_excluded") or [])
if excluded:
current.add(entity_id)
else:
current.discard(entity_id)
obj["battery_fleet_excluded"] = sorted(current)
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(entry, data=new_data)
return True
def find_fleet_task(entry: ConfigEntry) -> tuple[str, dict[str, Any]] | None:
"""The flagged fleet task (id, data) on the fleet entry, or None."""
for task_id, task_data in (entry.data.get(CONF_TASKS) or {}).items():
@@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant
def normalize_language(hass: HomeAssistant) -> str:
"""Return the HA UI language as a lowercase 2-letter table key.
"""Return the HA UI language as a lowercase table key.
HA emits regional language codes (e.g. ``zh-Hans``, ``zh-Hant``,
``pt-BR``), but the integration's localization tables — the
@@ -15,5 +15,21 @@ def normalize_language(hass: HomeAssistant) -> str:
are keyed by the bare 2-letter prefix. Centralizing the normalization
keeps every consumer identical, so a regional-code user never silently
falls back to English. Defaults to ``en`` when the language is unset.
Brazilian Portuguese is the one regional variant with its OWN tables
(``pt-br``) it must not collapse into European ``pt``.
"""
return (getattr(hass.config, "language", None) or "en")[:2].lower()
return normalize_language_code(getattr(hass.config, "language", None))
def normalize_language_code(code: str | None) -> str:
"""Normalize a raw language code to a table key (idempotent).
Every consumer that accepts an explicit ``language`` parameter must run
it through here instead of truncating to two letters itself a bare
``[:2]`` would collapse ``pt-BR`` into European ``pt``.
"""
lang = str(code or "en").lower()
if lang.startswith("pt") and lang.endswith("br"):
return "pt-br"
return lang[:2]
@@ -477,6 +477,98 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"budget_alert_monthly": "Månadsbudget på {pct}% ({spent} av {budget})",
"budget_alert_yearly": "Årsbudget på {pct}% ({spent} av {budget})",
},
"pt-br": {
"due_soon_title": "Manutenção em breve",
"due_soon_message": "{task} de {object} vence em {days} dia(s) (vencimento: {due}).",
"overdue_title": "Manutenção atrasada!",
"overdue_message": "{task} de {object} está {days} dia(s) atrasada!",
"triggered_title": "Manutenção acionada",
"triggered_message": "{task} de {object} foi acionada por dados de sensor.",
"action_complete": "Concluir",
"action_skip": "Pular",
"action_snooze": "Adiar",
"bundled_title": "Manutenção: {count} tarefas",
"bundled_message": "{object}: {task_list}",
"digest_title": "Resumo semanal de manutenção",
"digest_message": "{overdue} atrasadas, {due_soon} vencem nesta semana.",
"warranty_title": "Garantia expirando em breve",
"warranty_message": "{count} objeto(s) com garantia expirando em {days} dias: {names}",
"bundled_overdue": "{task} (atrasada)",
"bundled_due_soon": "{task} (em breve)",
"bundled_triggered": "{task} (acionada)",
"budget_alert_title": "Alerta de orçamento de manutenção",
"budget_alert_monthly": "Orçamento mensal em {pct}% ({spent} de {budget})",
"budget_alert_yearly": "Orçamento anual em {pct}% ({spent} de {budget})",
},
"hu": {
"due_soon_title": "Karbantartás hamarosan esedékes",
"due_soon_message": "{object} {task} {days} nap múlva esedékes (határidő: {due}).",
"overdue_title": "Karbantartás lejárt!",
"overdue_message": "{object} {task} {days} napja esedékes!",
"triggered_title": "Karbantartás aktiválódott",
"triggered_message": "{object} {task} feladatot érzékelőadatok aktiválták.",
"action_complete": "Kész",
"action_skip": "Kihagyás",
"action_snooze": "Halasztás",
"bundled_title": "Karbantartás: {count} feladat",
"bundled_message": "{object}: {task_list}",
"digest_title": "Heti karbantartási összefoglaló",
"digest_message": "{overdue} lejárt, {due_soon} esedékes ezen a héten.",
"warranty_title": "Hamarosan lejáró garancia",
"warranty_message": "{count} objektum garanciája jár le {days} napon belül: {names}",
"bundled_overdue": "{task} (lejárt)",
"bundled_due_soon": "{task} (hamarosan)",
"bundled_triggered": "{task} (aktiválva)",
"budget_alert_title": "Karbantartási keret figyelmeztetés",
"budget_alert_monthly": "Havi keret {pct}%-on ({spent} / {budget})",
"budget_alert_yearly": "Éves keret {pct}%-on ({spent} / {budget})",
},
"ko": {
"due_soon_title": "곧 예정된 유지보수",
"due_soon_message": "{object}{task}이(가) {days}일 후 예정입니다 (기한: {due}).",
"overdue_title": "유지보수 기한 초과!",
"overdue_message": "{object}{task}이(가) {days}일 지났습니다!",
"triggered_title": "유지보수 트리거됨",
"triggered_message": "{object}{task}이(가) 센서 데이터로 트리거되었습니다.",
"action_complete": "완료",
"action_skip": "건너뛰기",
"action_snooze": "미루기",
"bundled_title": "유지보수: 작업 {count}",
"bundled_message": "{object}: {task_list}",
"digest_title": "주간 유지보수 요약",
"digest_message": "기한 초과 {overdue}건, 이번 주 예정 {due_soon}건.",
"warranty_title": "보증 기간 만료 임박",
"warranty_message": "{days}일 이내에 보증이 만료되는 객체 {count}개: {names}",
"bundled_overdue": "{task} (기한 초과)",
"bundled_due_soon": "{task} (곧 예정)",
"bundled_triggered": "{task} (트리거됨)",
"budget_alert_title": "유지보수 예산 경고",
"budget_alert_monthly": "월 예산 {pct}% 사용 ({budget}{spent})",
"budget_alert_yearly": "연 예산 {pct}% 사용 ({budget}{spent})",
},
"tr": {
"due_soon_title": "Bakım zamanı yaklaşıyor",
"due_soon_message": "{object} için {task}, {days} gün içinde yapılmalı (Tarih: {due}).",
"overdue_title": "Bakım gecikti!",
"overdue_message": "{object} için {task}, {days} gün gecikti!",
"triggered_title": "Bakım tetiklendi",
"triggered_message": "{object} için {task}, sensör verileriyle tetiklendi.",
"action_complete": "Tamamla",
"action_skip": "Atla",
"action_snooze": "Ertele",
"bundled_title": "Bakım: {count} görev",
"bundled_message": "{object}: {task_list}",
"digest_title": "Haftalık bakım özeti",
"digest_message": "{overdue} gecikmiş, {due_soon} bu hafta yapılacak.",
"warranty_title": "Garanti yakında sona eriyor",
"warranty_message": "{days} gün içinde garantisi sona erecek {count} nesne: {names}",
"bundled_overdue": "{task} (gecikmiş)",
"bundled_due_soon": "{task} (yaklaşıyor)",
"bundled_triggered": "{task} (tetiklendi)",
"budget_alert_title": "Bakım bütçesi uyarısı",
"budget_alert_monthly": "Aylık bütçe %{pct} ({spent} / {budget})",
"budget_alert_yearly": "Yıllık bütçe %{pct} ({spent} / {budget})",
},
}
@@ -76,6 +76,10 @@ _BUY_NAME_TEMPLATES = {
"sv": "Köp {name}",
"uk": "Купити {name}",
"zh": "购买{name}",
"pt-br": "Comprar {name}",
"hu": "{name} vásárlása",
"ko": "{name} 구매",
"tr": "{name} satın al",
}
# Default shopping-search templates by UI language (the "Amazon as fallback"
@@ -86,6 +90,8 @@ _DEFAULT_SEARCH_TEMPLATES = {
"it": "https://www.amazon.it/s?k={q}",
"es": "https://www.amazon.es/s?k={q}",
"nl": "https://www.amazon.nl/s?k={q}",
"pt-br": "https://www.amazon.com.br/s?k={q}",
"tr": "https://www.amazon.com.tr/s?k={q}",
}
_FALLBACK_SEARCH_TEMPLATE = "https://www.amazon.com/s?k={q}"
@@ -275,7 +281,9 @@ def stock_transition(part: Mapping[str, Any], old: float | None, new: float | No
def default_search_template(lang: str) -> str:
return _DEFAULT_SEARCH_TEMPLATES.get((lang or "en")[:2].lower(), _FALLBACK_SEARCH_TEMPLATE)
from .i18n import normalize_language_code
return _DEFAULT_SEARCH_TEMPLATES.get(normalize_language_code(lang), _FALLBACK_SEARCH_TEMPLATE)
def search_query(part: Mapping[str, Any]) -> str:
@@ -303,7 +311,9 @@ def resolve_shopping_url(part: Mapping[str, Any], template: str | None, lang: st
def buy_task_name(part_name: str, lang: str) -> str:
tpl = _BUY_NAME_TEMPLATES.get((lang or "en")[:2].lower(), _BUY_NAME_TEMPLATES["en"])
from .i18n import normalize_language_code
tpl = _BUY_NAME_TEMPLATES.get(normalize_language_code(lang), _BUY_NAME_TEMPLATES["en"])
return tpl.replace("{name}", part_name)
@@ -147,6 +147,8 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
if rs not in ROTATION_STRATEGIES:
task_data.pop("rotation_strategy", None)
seed_rotation_assignee(task_data)
# v1.3.0: per-task on_complete_action — embedded HA service-call config.
# Strict shape: {service: "domain.name", target?: dict, data?: dict}.
# Drops the field entirely on any structural problem; the action layer
@@ -190,6 +192,25 @@ def sanitize_labels(value: object) -> list[str]:
return out[:MAX_LABELS]
def seed_rotation_assignee(task_data: dict[str, Any]) -> None:
"""Ensure a rotation task always carries an effective assignee.
The rotation resolves "who is on duty" by writing the next pool member
into ``responsible_user_id`` on completion the field EVERY user filter
reads (panel, Lovelace card, calendar card, saved views, per-user
notifications). But a rotation could be configured without an initial
assignee, leaving the task invisible to all of those until its first
completion ran ``advance_rotation`` (discussion #49). Seed the first
pool member when the assignee is missing or no longer in the pool
(the pool was edited out from under the current assignee).
"""
pool = [u for u in task_data.get("assignee_pool") or [] if u]
if len(pool) < 2 or not task_data.get("rotation_strategy"):
return
if task_data.get("responsible_user_id") not in pool:
task_data["responsible_user_id"] = pool[0]
def sanitize_assignee_pool(value: object) -> list[str]:
"""Clean an assignee pool: str user-ids, trimmed, deduped, ≤ MAX_ASSIGNEE_POOL."""
if not isinstance(value, list):
@@ -19,23 +19,56 @@ from ._model import (
from ._registry import SIGNATURES
def _entity_watchers(hass: HomeAssistant) -> dict[str, set[str]]:
"""entity_id → lowercased names of the tasks watching it via a trigger."""
from ...const import CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID
from ...entity.triggers import normalize_entity_ids
out: dict[str, set[str]] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
for task in entry.data.get(CONF_TASKS, {}).values():
tc = task.get("trigger_config")
if isinstance(tc, dict):
name = str(task.get("name", "")).lower()
for eid in normalize_entity_ids(tc):
out.setdefault(eid, set()).add(name)
return out
def _catalog_name_variants() -> set[str]:
"""Every catalog task name in every language, lowercased — to recognise
whether an existing watcher task is one of OUR duties or a custom one."""
variants: set[str] = set()
for catalog in SIGNATURES.values():
for sig in catalog.tasks:
variants.update(task_name_variants(sig.task_name))
return variants
def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Devices of catalogued integrations with their matchable task wiring.
Groups matched entities per device; carries the maintenance object already
attached to the device (if any) so adoption can extend it instead of
creating a duplicate. Entities already watched by some task's trigger are
skipped re-running discovery never proposes what is already wired.
creating a duplicate. Entity claims are per DUTY, not per entity: a task
already watching an entity blocks only its own duty (recognised by
catalog name in any language), so a mower's hours counter still proposes
"Clean Undercarriage" after "Replace Mower Blades" was adopted. A watcher
with a custom/renamed name conservatively claims the whole entity
re-running discovery never re-proposes against a rename.
"""
from ...templates import localize_template_text
from ..i18n import normalize_language
from ..problem_sensors import _adopted_entity_ids, _object_by_device
from ..problem_sensors import _object_by_device
lang = normalize_language(hass)
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
area_reg = ar.async_get(hass)
already_watched = _adopted_entity_ids(hass)
watchers = _entity_watchers(hass)
known_variants = _catalog_name_variants()
by_device = _object_by_device(hass)
# Collect the enabled registry entities of cataloged integrations per
@@ -69,10 +102,16 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
any(_entity_matches(e, key) for key in sig.require_sibling_keys) for e in entries
):
continue
variants = task_name_variants(sig.task_name)
for entry in entries:
if entry.domain != sig.entity_domain:
continue
if entry.entity_id in already_watched:
# Per-duty claims: a watcher task named as THIS duty (any
# language) blocks it; watchers named as other catalog duties
# leave the remaining duties adoptable; a custom/renamed
# watcher claims the whole entity.
watcher_names = watchers.get(entry.entity_id)
if watcher_names and (watcher_names & variants or watcher_names - known_variants):
continue
if not _unit_compatible(sig.direction, _entity_unit(hass, entry)):
continue
@@ -86,9 +125,9 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
)
group["entity_ids"].append(entry.entity_id)
# One source entity may back SEVERAL duties (a mower's hours
# counter drives blades AND undercarriage). Adopting any duty
# marks the entity watched — adopt-all is the default,
# deselecting a duty forfeits its later proposal.
# counter drives blades AND undercarriage) — both within one
# run and across runs: the per-duty claim above keeps a
# deselected duty proposable after its sibling was adopted.
out: list[dict[str, Any]] = []
for device_id, sig_map in matched.items():
@@ -158,9 +158,11 @@ _SPEECH: dict[str, dict[str, str]] = {
def _sp(key: str, language: str | None, **fmt: Any) -> str:
# Per-request language (intent_obj.language, e.g. "de-DE") → bare 2-letter
# key, same normalization rule as helpers/i18n (which is hass-bound).
lang = (language or "en")[:2].lower()
# Per-request language (intent_obj.language, e.g. "de-DE") → table key,
# same normalization rule as helpers/i18n (which is hass-bound).
from .helpers.i18n import normalize_language_code
lang = normalize_language_code(language)
table = _SPEECH[key]
return table.get(lang, table["en"]).format(**fmt)
@@ -164,6 +164,34 @@ _STRINGS: dict[str, dict[str, str]] = {
"trigger_on": "传感器触发器已激活",
"trigger_off": "传感器触发器已清除",
},
"pt-br": {
"completed": "foi concluída",
"skipped": "foi pulada",
"reset": "foi redefinida para {date}",
"trigger_on": "gatilho de sensor ativado",
"trigger_off": "gatilho de sensor desativado",
},
"hu": {
"completed": "elkészült",
"skipped": "kihagyva",
"reset": "visszaállítva erre: {date}",
"trigger_on": "érzékelő-trigger aktiválódott",
"trigger_off": "érzékelő-trigger megszűnt",
},
"ko": {
"completed": "완료됨",
"skipped": "건너뜀",
"reset": "{date}(으)로 재설정됨",
"trigger_on": "센서 트리거 활성화됨",
"trigger_off": "센서 트리거 해제됨",
},
"tr": {
"completed": "tamamlandı",
"skipped": "atlandı",
"reset": "{date} tarihine sıfırlandı",
"trigger_on": "sensör tetikleyicisi etkinleşti",
"trigger_off": "sensör tetikleyicisi temizlendi",
},
}
@@ -22,5 +22,5 @@
"requirements": [
"pypdf>=4.3.0"
],
"version": "2.39.0"
"version": "2.42.1"
}
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
from homeassistant.const import UnitOfInformation
from homeassistant.const import UnitOfInformation, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@@ -100,6 +100,10 @@ async def async_setup_entry(
# next-due instant for tile/entities cards with the relative time-format
# display options ("in 2 days"), template automations, etc.
entities.extend(MaintenanceNextDueSensor(coordinator, task_id) for task_id in tasks)
# Numeric countdown companion per task (disabled by default): plain
# days-until-due number for gauge/progress-bar cards, which cannot read
# the status sensor's attribute.
entities.extend(MaintenanceDaysUntilDueSensor(coordinator, task_id) for task_id in tasks)
# Spare parts: one stock sensor per part on the object device (catalog-only
# parts without a tracked count read unavailable).
from .const import CONF_PARTS
@@ -494,6 +498,49 @@ class MaintenanceNextDueSensor(MaintenanceEntity, SensorEntity):
return False
class MaintenanceDaysUntilDueSensor(MaintenanceEntity, SensorEntity):
"""Numeric days-until-due countdown for a task.
Companion to the per-task status sensor, disabled by default like the
next-due timestamp twin always-on would double the entity count for
large setups. Once enabled its STATE is the plain number of days until
the task is due (negative once overdue), which is what gauge and
progress-bar cards need; the status sensor only carries this as an
attribute. Manual, archived and trigger-only tasks read unknown.
"""
_attr_translation_key = "days_until_due"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = UnitOfTime.DAYS
_attr_suggested_display_precision = 0
_attr_icon = "mdi:calendar-clock"
_attr_entity_registry_enabled_default = False
def __init__(
self,
coordinator: MaintenanceCoordinator,
task_id: str,
) -> None:
"""Initialize the days-until-due sensor."""
super().__init__(coordinator, task_id)
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_days_until_due"
self._attr_translation_placeholders = {"task_name": task_data.get("name", "")}
@property
def native_value(self) -> int | None:
"""Days until due (negative = overdue), or None without a due date."""
task = self._task_data
if not task:
return None
if task.get("_status") == MaintenanceStatus.ARCHIVED:
return None
days = task.get("_days_until_due")
return int(days) if days is not None else None
class MaintenanceSummarySensor(CoordinatorEntity[MaintenanceSummaryCoordinator], SensorEntity):
"""Aggregate count sensor on the global Maintenance Supporter hub device.
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Next due"
},
"days_until_due": {
"name": "{task_name} Days until due"
},
"parts_to_reorder": {
"name": "Parts to reorder"
},
@@ -50,6 +50,10 @@ TEMPLATE_CATEGORIES: dict[str, dict[str, str]] = {
"name_fi": "Ajoneuvo",
"name_ja": "乗り物",
"name_hi": "वाहन",
"name_pt-br": "Veículos",
"name_hu": "Járművek",
"name_ko": "차량",
"name_tr": "Araç",
},
"home": {
"icon": "mdi:home",
@@ -71,6 +75,10 @@ TEMPLATE_CATEGORIES: dict[str, dict[str, str]] = {
"name_fi": "Koti ja LVI",
"name_ja": "住宅設備",
"name_hi": "घर और HVAC",
"name_pt-br": "Casa e HVAC",
"name_hu": "Otthon és HVAC",
"name_ko": "주택 및 HVAC",
"name_tr": "Ev ve HVAC",
},
# v2.27: two extra top-level groups keep the growing catalog scannable —
# recurring HOUSEHOLD routines split from device-centric "home", and
@@ -96,6 +104,10 @@ TEMPLATE_CATEGORIES: dict[str, dict[str, str]] = {
"name_ja": "家事・ルーティン",
"name_hi": "गृहकार्य और दिनचर्या",
"name_zh": "家务与日常",
"name_pt-br": "Lar e rotinas",
"name_hu": "Háztartás és rutinok",
"name_ko": "가사 및 루틴",
"name_tr": "Ev İşleri ve Rutinler",
},
"garden": {
"icon": "mdi:tree",
@@ -117,6 +129,10 @@ TEMPLATE_CATEGORIES: dict[str, dict[str, str]] = {
"name_ja": "庭・屋外",
"name_hi": "बगीचा और बाहरी क्षेत्र",
"name_zh": "花园与户外",
"name_pt-br": "Jardim e área externa",
"name_hu": "Kert és szabadtér",
"name_ko": "정원 및 야외",
"name_tr": "Bahçe ve Dış Mekân",
},
"pool": {
"icon": "mdi:pool",
@@ -138,6 +154,10 @@ TEMPLATE_CATEGORIES: dict[str, dict[str, str]] = {
"name_fi": "Uima-allas",
"name_ja": "プール",
"name_hi": "पूल",
"name_pt-br": "Piscina",
"name_hu": "Medence",
"name_ko": "수영장",
"name_tr": "Havuz",
},
"appliance": {
"icon": "mdi:washing-machine",
@@ -159,6 +179,10 @@ TEMPLATE_CATEGORIES: dict[str, dict[str, str]] = {
"name_fi": "Kodinkoneet",
"name_ja": "家電",
"name_hi": "उपकरण",
"name_pt-br": "Eletrodomésticos",
"name_hu": "Háztartási gépek",
"name_ko": "가전제품",
"name_tr": "Ev Aletleri",
},
"pets": {
"icon": "mdi:paw",
@@ -180,6 +204,10 @@ TEMPLATE_CATEGORIES: dict[str, dict[str, str]] = {
"name_fi": "Lemmikit",
"name_ja": "ペット",
"name_hi": "पालतू जानवर",
"name_pt-br": "Pets",
"name_hu": "Háziállatok",
"name_ko": "반려동물",
"name_tr": "Evcil Hayvanlar",
},
"tech": {
"icon": "mdi:server",
@@ -201,6 +229,35 @@ TEMPLATE_CATEGORIES: dict[str, dict[str, str]] = {
"name_fi": "Tekniikka ja IT",
"name_ja": "テクノロジー・IT",
"name_hi": "टेक और IT",
"name_pt-br": "Tecnologia e TI",
"name_hu": "Technika és IT",
"name_ko": "기술 및 IT",
"name_tr": "Teknoloji ve BT",
},
"health": {
"icon": "mdi:heart-pulse",
"name_en": "Health",
"name_de": "Gesundheit",
"name_nl": "Gezondheid",
"name_fr": "Santé",
"name_it": "Salute",
"name_es": "Salud",
"name_ru": "Здоровье",
"name_uk": "Здоров'я",
"name_pt": "Saúde",
"name_zh": "健康",
"name_pl": "Zdrowie",
"name_cs": "Zdraví",
"name_sv": "Hälsa",
"name_da": "Sundhed",
"name_nb": "Helse",
"name_fi": "Terveys",
"name_ja": "健康",
"name_hi": "स्वास्थ्य",
"name_pt-br": "Saúde",
"name_hu": "Egészség",
"name_ko": "건강",
"name_tr": "Sağlık",
},
}
@@ -805,6 +862,119 @@ TEMPLATES: list[ObjectTemplate] = [
),
],
),
# --- Health ---
# Roadmap 3a (the resmed_myair lesson): CPAP machines have real,
# manufacturer-specified upkeep but their integrations expose only
# therapy metrics — no wear sensors. Because a CPAP runs every night,
# calendar intervals track usage almost perfectly, so a static template
# IS the right trigger here. Intervals follow ResMed's replacement
# guidance; they suit other brands (Löwenstein, Philips) too.
ObjectTemplate(
id="health_cpap",
name="CPAP Machine",
category="health",
tasks=[
TaskTemplate(
"Clean Mask & Humidifier Tub",
"cleaning",
"time_based",
7,
2,
"Wash the mask cushion and humidifier tub in warm soapy water; air-dry away from direct sunlight.",
),
TaskTemplate("Replace Mask Cushion", "replacement", "time_based", 30, 7),
TaskTemplate(
"Air Filter",
"replacement",
"time_based",
30,
7,
"Replace sooner if it looks discolored or dusty.",
),
TaskTemplate("Replace Tubing", "replacement", "time_based", 90, 14),
TaskTemplate("Replace Humidifier Tub", "replacement", "time_based", 180, 21),
TaskTemplate("Replace Headgear & Frame", "replacement", "time_based", 180, 21),
TaskTemplate("Annual Service", "inspection", "time_based", 365, 30),
],
),
ObjectTemplate(
id="health_hearing_aids",
name="Hearing Aids",
category="health",
tasks=[
TaskTemplate(
"Deep Clean & Dry",
"cleaning",
"time_based",
7,
2,
"Brush off earwax and use a drying capsule or electronic dryer overnight.",
),
TaskTemplate("Replace Wax Guards", "replacement", "time_based", 30, 7),
TaskTemplate("Replace Domes", "replacement", "time_based", 90, 14),
TaskTemplate("Professional Check & Adjustment", "inspection", "time_based", 365, 30),
],
),
# --- Template-worthiness lens (roadmap 3b): device classes with real,
# manufacturer/guideline-specified maintenance and NO smart signals at
# all — the class the signature sweeps can never surface. ---
ObjectTemplate(
id="home_fire_safety",
name="Fire Safety Equipment",
category="home",
tasks=[
TaskTemplate(
"Inspect Fire Extinguisher",
"inspection",
"time_based",
180,
14,
"Check the pressure gauge, seal and pin; make sure it is accessible and undamaged.",
),
TaskTemplate(
"Fire Extinguisher Service",
"service",
"time_based",
730,
60,
"Professional inspection per the label — commonly every 2 years; replace the unit after 10-15 years.",
),
TaskTemplate(
"Check First-Aid Kit",
"inspection",
"time_based",
180,
14,
"Replace expired sterile items and restock anything used.",
),
],
),
ObjectTemplate(
id="pets_aquarium",
name="Aquarium",
category="pets",
tasks=[
TaskTemplate(
"Partial Water Change",
"cleaning",
"time_based",
14,
3,
"Change 20-30 % of the water; match temperature and treat tap water with conditioner.",
),
TaskTemplate("Test Water Values", "inspection", "time_based", 14, 3),
TaskTemplate(
"Clean Filter Media",
"cleaning",
"time_based",
30,
7,
"Rinse media in removed tank water - never under the tap, that kills the bacteria culture.",
),
TaskTemplate("Replace Activated Carbon", "replacement", "time_based", 30, 7),
TaskTemplate("Clean Glass & Decor", "cleaning", "time_based", 30, 7),
],
),
]
File diff suppressed because it is too large Load Diff
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Příští termín"
},
"days_until_due": {
"name": "{task_name} Dnů do termínu"
},
"parts_to_reorder": {
"name": "Díly k doobjednání"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Næste forfald"
},
"days_until_due": {
"name": "{task_name} Dage til forfald"
},
"parts_to_reorder": {
"name": "Dele til genbestilling"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Nächste Fälligkeit"
},
"days_until_due": {
"name": "{task_name} Tage bis fällig"
},
"parts_to_reorder": {
"name": "Nachzukaufende Teile"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Next due"
},
"days_until_due": {
"name": "{task_name} Days until due"
},
"parts_to_reorder": {
"name": "Parts to reorder"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Próximo vencimiento"
},
"days_until_due": {
"name": "{task_name} Días hasta vencimiento"
},
"parts_to_reorder": {
"name": "Piezas por reponer"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Seuraava eräpäivä"
},
"days_until_due": {
"name": "{task_name} Päiviä eräpäivään"
},
"parts_to_reorder": {
"name": "Tilattavat osat"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Prochaine échéance"
},
"days_until_due": {
"name": "{task_name} Jours avant échéance"
},
"parts_to_reorder": {
"name": "Pièces à racheter"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} अगली नियत तिथि"
},
"days_until_due": {
"name": "{task_name} देय तक दिन"
},
"parts_to_reorder": {
"name": "पुनः ऑर्डर करने योग्य पुर्ज़े"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Prossima scadenza"
},
"days_until_due": {
"name": "{task_name} Giorni alla scadenza"
},
"parts_to_reorder": {
"name": "Ricambi da riordinare"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} 次回期日"
},
"days_until_due": {
"name": "{task_name} 期限までの日数"
},
"parts_to_reorder": {
"name": "再注文が必要な部品"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Neste forfall"
},
"days_until_due": {
"name": "{task_name} Dager til forfall"
},
"parts_to_reorder": {
"name": "Deler å etterbestille"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Volgende vervaldatum"
},
"days_until_due": {
"name": "{task_name} Dagen tot deadline"
},
"parts_to_reorder": {
"name": "Onderdelen bij te bestellen"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Następny termin"
},
"days_until_due": {
"name": "{task_name} Dni do terminu"
},
"parts_to_reorder": {
"name": "Części do zamówienia"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Próximo vencimento"
},
"days_until_due": {
"name": "{task_name} Dias até ao vencimento"
},
"parts_to_reorder": {
"name": "Peças a repor"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Следующий срок"
},
"days_until_due": {
"name": "{task_name} Дней до срока"
},
"parts_to_reorder": {
"name": "Детали к дозаказу"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Nästa förfallodatum"
},
"days_until_due": {
"name": "{task_name} Dagar till förfall"
},
"parts_to_reorder": {
"name": "Delar att beställa"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} Наступний термін"
},
"days_until_due": {
"name": "{task_name} Днів до терміну"
},
"parts_to_reorder": {
"name": "Деталі до замовлення"
},
@@ -1369,6 +1369,9 @@
"next_due": {
"name": "{task_name} 下次到期"
},
"days_until_due": {
"name": "{task_name} 距离到期天数"
},
"parts_to_reorder": {
"name": "待补购配件"
},
@@ -429,6 +429,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .battery_fleet import (
ws_battery_fleet_mark_replaced,
ws_battery_fleet_overview,
ws_battery_fleet_set_excluded,
ws_battery_fleet_setup,
)
from .dashboard import (
@@ -467,6 +468,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_get_templates,
ws_import_csv,
ws_import_json,
ws_version,
)
from .objects import (
ws_archive_object,
@@ -556,6 +558,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_restock_part)
websocket_api.async_register_command(hass, ws_update_history_entry)
websocket_api.async_register_command(hass, ws_get_templates)
websocket_api.async_register_command(hass, ws_version)
websocket_api.async_register_command(hass, ws_export_data)
websocket_api.async_register_command(hass, ws_get_budget_status)
websocket_api.async_register_command(hass, ws_schedule_preview)
@@ -568,6 +571,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_battery_fleet_overview)
websocket_api.async_register_command(hass, ws_battery_fleet_setup)
websocket_api.async_register_command(hass, ws_battery_fleet_mark_replaced)
websocket_api.async_register_command(hass, ws_battery_fleet_set_excluded)
websocket_api.async_register_command(hass, ws_discover_integration_setups)
websocket_api.async_register_command(hass, ws_adopt_integration_setups)
websocket_api.async_register_command(hass, ws_list_saved_views)
@@ -14,12 +14,13 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import DOMAIN, MAX_ENTITY_ID_LENGTH
from ..helpers.battery_fleet import compute_overview, has_batteries, has_battery_notes
from ..helpers.battery_fleet import compute_overview, fleet_excluded_entities, has_batteries, has_battery_notes
from ..helpers.battery_fleet_setup import (
async_mark_replaced,
async_setup_battery_fleet,
find_fleet_entry,
fleet_task_trigger_ok,
set_battery_excluded,
)
from ..helpers.permissions import require_write
@@ -47,6 +48,19 @@ async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_a
"needs_now": dict(ov.needs_now),
"needs_soon": dict(ov.needs_soon),
"types": ov.types,
# Manually excluded batteries (issue #107) — names enriched where
# the entity still exists, so the restore list stays readable.
"excluded": [
{
"entity_id": eid,
"device_name": (
(st := hass.states.get(eid)) is not None
and (st.attributes.get("device_name") or st.attributes.get("friendly_name"))
)
or eid,
}
for eid in sorted(fleet_excluded_entities(hass))
],
},
)
@@ -70,6 +84,25 @@ async def ws_battery_fleet_setup(hass: HomeAssistant, connection: websocket_api.
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/set_excluded",
vol.Required("entity_id"): vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)),
vol.Required("excluded"): bool,
}
)
@require_write
@websocket_api.async_response
async def ws_battery_fleet_set_excluded(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Manually exclude a battery from the fleet (or take it back in) — #107."""
if not set_battery_excluded(hass, msg["entity_id"], msg["excluded"]):
connection.send_error(msg["id"], "not_configured", "Battery Fleet is not set up")
return
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/mark_replaced",
@@ -88,6 +88,22 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
return out
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/version"})
@websocket_api.async_response
async def ws_version(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""The installed integration version (manifest).
Roadmap guard 2 stale-bundle handshake: the panel compares this against
the version esbuild stamped into its bundle and offers a reload when a
cached old frontend is talking to a newer backend (HA's service worker
updates stale-while-revalidate, so this happens routinely after updates).
"""
from homeassistant.loader import async_get_integration
integration = await async_get_integration(hass, DOMAIN)
connection.send_result(msg["id"], {"version": integration.version})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/templates",
@@ -108,7 +124,7 @@ async def ws_get_templates(
curation): the pickers hide disabled ones client-side, while the Settings
section needs the full list to render the toggles.
"""
from ..helpers.i18n import normalize_language
from ..helpers.i18n import normalize_language, normalize_language_code
from ..templates import (
TEMPLATE_CATEGORIES,
TEMPLATES,
@@ -117,7 +133,7 @@ async def ws_get_templates(
)
disabled = get_disabled_template_ids(hass)
lang = (msg.get("language") or normalize_language(hass))[:2].lower()
lang = normalize_language_code(msg.get("language")) if msg.get("language") else normalize_language(hass)
result = {
"categories": {cat_id: {k: v for k, v in cat.items()} for cat_id, cat in TEMPLATE_CATEGORIES.items()},
@@ -554,6 +570,11 @@ async def ws_import_json(
wd = task_data.get("warning_days")
if not isinstance(wd, int) or wd < 0 or wd > 365:
task_data["warning_days"] = get_default_warning_days(hass)
# A rotation task must carry its effective assignee (imports from
# pre-seeding exports may lack one) — same rule as create/update.
from ..helpers.sanitize import seed_rotation_assignee
seed_rotation_assignee(task_data)
# Sanitize checklist: only keep string items within length budget,
# cap total items. Drops malformed entries silently rather than
# rejecting the whole import — same forgiving model as the other
@@ -523,7 +523,7 @@ async def ws_create_from_template(
"""
from uuid import uuid4
from ..helpers.i18n import normalize_language
from ..helpers.i18n import normalize_language, normalize_language_code
from ..templates import get_template_by_id, localize_template_text
template = get_template_by_id(msg["template_id"])
@@ -531,7 +531,7 @@ async def ws_create_from_template(
connection.send_error(msg["id"], "not_found", "Template not found")
return
lang = (msg.get("language") or normalize_language(hass))[:2].lower()
lang = normalize_language_code(msg.get("language")) if msg.get("language") else normalize_language(hass)
default_name = localize_template_text(template.name, lang) or template.name
name = (msg.get("name") or default_name).strip() or default_name
# Auto-number on collision: applying the same template twice (or owning
@@ -68,6 +68,47 @@ from .tasks_validation import (
_validate_trigger_config,
)
# ws_update_task: wire key -> storage key. Almost all are identity; the one
# rename is deliberate and load-bearing: the WS message envelope reserves
# "type" for command routing ({"type": "maintenance_supporter/task/update"}),
# so a task's own type must travel as "task_type" on the wire and is stored
# as "type". Do NOT "simplify" this to "type": "type" — it would collide with
# the routing key. The panel↔config-flow parity test (test_parity_task_fields)
# encodes the same task_type->type alias. Module-level so the CONTRACT-FIXTURE
# tripwire (tests/test_task_contract_fixture.py) can enumerate it: a field
# added here without extending the round-trip fixture fails that test.
TASK_UPDATE_FIELD_MAP = {
"name": "name",
"task_type": "type",
"enabled": "enabled",
"schedule_type": "schedule_type",
"interval_days": "interval_days",
"interval_unit": "interval_unit",
"due_date": "due_date",
"interval_anchor": "interval_anchor",
"warning_days": "warning_days",
"earliest_completion_days": "earliest_completion_days",
"last_performed": "last_performed",
"trigger_config": "trigger_config",
"notes": "notes",
"documentation_url": "documentation_url",
"responsible_user_id": "responsible_user_id",
"assignee_pool": "assignee_pool",
"rotation_strategy": "rotation_strategy",
"entity_slug": "entity_slug",
"custom_icon": "custom_icon",
"nfc_tag_id": "nfc_tag_id",
"reading_unit": "reading_unit",
"consumes_parts": "consumes_parts",
"priority": "priority",
"checklist": "checklist",
"labels": "labels",
"schedule_time": "schedule_time",
# v1.3.0
"on_complete_action": "on_complete_action",
"quick_complete_defaults": "quick_complete_defaults",
}
@websocket_api.websocket_command(
{
@@ -242,6 +283,9 @@ async def ws_create_task(
task_data["assignee_pool"] = sanitize_assignee_pool(msg["assignee_pool"])
if msg.get("rotation_strategy"):
task_data["rotation_strategy"] = msg["rotation_strategy"]
from ..helpers.sanitize import seed_rotation_assignee
seed_rotation_assignee(task_data)
if msg.get("entity_slug") is not None:
slug = msg["entity_slug"]
if not re.fullmatch(r"[a-z0-9_]+", slug):
@@ -452,45 +496,7 @@ async def ws_update_task(
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
return
# Update provided fields. Wire key -> storage key. Almost all are identity;
# the one rename is deliberate and load-bearing: the WS message envelope
# reserves "type" for command routing ({"type": "maintenance_supporter/
# task/update"}), so a task's own type must travel as "task_type" on the
# wire and is stored as "type". Do NOT "simplify" this to "type": "type"
# — it would collide with the routing key. The panel↔config-flow parity
# test (test_parity_task_fields) encodes the same task_type->type alias.
field_map = {
"name": "name",
"task_type": "type",
"enabled": "enabled",
"schedule_type": "schedule_type",
"interval_days": "interval_days",
"interval_unit": "interval_unit",
"due_date": "due_date",
"interval_anchor": "interval_anchor",
"warning_days": "warning_days",
"earliest_completion_days": "earliest_completion_days",
"last_performed": "last_performed",
"trigger_config": "trigger_config",
"notes": "notes",
"documentation_url": "documentation_url",
"responsible_user_id": "responsible_user_id",
"assignee_pool": "assignee_pool",
"rotation_strategy": "rotation_strategy",
"entity_slug": "entity_slug",
"custom_icon": "custom_icon",
"nfc_tag_id": "nfc_tag_id",
"reading_unit": "reading_unit",
"consumes_parts": "consumes_parts",
"priority": "priority",
"checklist": "checklist",
"labels": "labels",
"schedule_time": "schedule_time",
# v1.3.0
"on_complete_action": "on_complete_action",
"quick_complete_defaults": "quick_complete_defaults",
}
for msg_key, data_key in field_map.items():
for msg_key, data_key in TASK_UPDATE_FIELD_MAP.items():
if msg_key in msg:
task[data_key] = msg[msg_key]
@@ -534,6 +540,7 @@ async def ws_update_task(
cap_quick_complete_defaults_field,
sanitize_assignee_pool,
sanitize_labels,
seed_rotation_assignee,
)
cap_action_field(task)
@@ -542,6 +549,7 @@ async def ws_update_task(
task["labels"] = sanitize_labels(task["labels"])
if "assignee_pool" in task:
task["assignee_pool"] = sanitize_assignee_pool(task["assignee_pool"])
seed_rotation_assignee(task)
# Clear stale trigger runtime in Store only when trigger fundamentally changes
if "trigger_config" in msg: